From 8b9c9d7e3889185395cca798daced45c9fb16af2 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Wed, 4 Sep 2024 14:58:13 +0200 Subject: [PATCH 01/32] qa: extract `user_from_pre_member` --- pycroft/lib/user.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pycroft/lib/user.py b/pycroft/lib/user.py index 26aa826bf..69d834f38 100644 --- a/pycroft/lib/user.py +++ b/pycroft/lib/user.py @@ -1363,7 +1363,6 @@ def user_from_pre_member(pre_member: PreMember, processor: User) -> User: return user - @with_transaction def confirm_mail_address( key: str, From b6d9590b2e8d3932a74ff024879ccc8b95cde8ab Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Wed, 4 Sep 2024 15:21:38 +0200 Subject: [PATCH 02/32] Turn pycroft.lib.user into package --- pycroft/lib/{user.py => user/__init__.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pycroft/lib/{user.py => user/__init__.py} (100%) diff --git a/pycroft/lib/user.py b/pycroft/lib/user/__init__.py similarity index 100% rename from pycroft/lib/user.py rename to pycroft/lib/user/__init__.py From 21eb6d9c85abf97a2ae36c369052217bacaa6e83 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Wed, 4 Sep 2024 15:28:05 +0200 Subject: [PATCH 03/32] move `pycroft.lib.user` contents into `_old` module --- pycroft/lib/task.py | 6 +- pycroft/lib/user/__init__.py | 1663 ++-------------------------- pycroft/lib/user/_old.py | 1593 ++++++++++++++++++++++++++ pycroft/lib/user/member_request.py | 0 web/blueprints/user/__init__.py | 2 +- 5 files changed, 1670 insertions(+), 1594 deletions(-) create mode 100644 pycroft/lib/user/_old.py create mode 100644 pycroft/lib/user/member_request.py diff --git a/pycroft/lib/task.py b/pycroft/lib/task.py index 5330f8078..19aade82f 100644 --- a/pycroft/lib/task.py +++ b/pycroft/lib/task.py @@ -114,12 +114,13 @@ class UserMoveTaskImpl(UserTaskImpl[UserMoveParams]): def _execute(self, task: UserTask, parameters: UserMoveParams) -> None: from pycroft.lib import user as lib_user + from pycroft.lib.facilities import get_room if task.user.room is None: self.errors.append("Tried to move in user, " "but user was already living in a dormitory.") return - room = lib_user.get_room( + room = get_room( room_number=parameters.room_number, level=parameters.level, building_id=parameters.building_id, @@ -147,13 +148,14 @@ class UserMoveInTaskImpl(UserTaskImpl): def _execute(self, task: UserTask, parameters: UserMoveInParams) -> None: from pycroft.lib import user as lib_user + from pycroft.lib.facilities import get_room if task.user.room is not None: self.errors.append("Tried to move in user, " "but user was already living in a dormitory.") return - room = lib_user.get_room( + room = get_room( room_number=parameters.room_number, level=parameters.level, building_id=parameters.building_id, diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index 69d834f38..892707e78 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -1,1593 +1,74 @@ -# Copyright (c) 2015 The Pycroft Authors. See the AUTHORS file. -# This file is part of the Pycroft project and licensed under the terms of -# the Apache License, Version 2.0. See the LICENSE file for details. -""" -pycroft.lib.user -~~~~~~~~~~~~~~~~ - -This module contains. - -:copyright: (c) 2012 by AG DSN. -""" -import os -import re -import typing -import typing as t -from datetime import timedelta, date -from difflib import SequenceMatcher -from collections.abc import Iterable - -from sqlalchemy import exists, func, select, Boolean, String, ColumnElement, ScalarResult -from sqlalchemy.orm import Session - -from pycroft import config, property -from pycroft.helpers import user as user_helper, utc -from pycroft.helpers.errorcode import Type1Code, Type2Code -from pycroft.helpers.i18n import deferred_gettext -from pycroft.helpers.interval import closed, Interval, starting_from -from pycroft.helpers.printing import generate_user_sheet as generate_pdf -from pycroft.helpers.user import generate_random_str, login_hash -from pycroft.helpers.utc import DateTimeTz -from pycroft.lib.address import get_or_create_address -from pycroft.lib.exc import PycroftLibException -from pycroft.lib.facilities import get_room -from pycroft.lib.finance import user_has_paid -from pycroft.lib.logging import log_user_event, log_event -from pycroft.lib.mail import MailTemplate, Mail, UserConfirmEmailTemplate, \ - UserCreatedTemplate, \ - UserMovedInTemplate, MemberRequestPendingTemplate, \ - MemberRequestDeniedTemplate, \ - MemberRequestMergedTemplate, UserResetPasswordTemplate -from pycroft.lib.membership import make_member_of, remove_member_of -from pycroft.lib.net import get_free_ip, MacExistsException, \ - get_subnets_for_room -from pycroft.lib.swdd import get_relevant_tenancies -from pycroft.lib.task import schedule_user_task -from pycroft.model import session -from pycroft.model.address import Address -from pycroft.model.facilities import Room -from pycroft.model.finance import Account -from pycroft.model.host import IP, Host, Interface -from pycroft.model.session import with_transaction -from pycroft.model.task import TaskType, UserTask, TaskStatus -from pycroft.model.task_serialization import UserMoveParams, UserMoveOutParams, \ - UserMoveInParams -from pycroft.model.traffic import TrafficHistoryEntry -from pycroft.model.traffic import traffic_history as func_traffic_history -from pycroft.model.user import ( - User, - PreMember, - BaseUser, - RoomHistoryEntry, - PropertyGroup, - Membership, +from ._old import ( + encode_type1_user_id, + decode_type1_user_id, + encode_type2_user_id, + decode_type2_user_id, + check_user_id, + HostAliasExists, + setup_ipv4_networking, + store_user_sheet, + get_user_sheet, + reset_password, + can_target, + reset_wifi_password, + maybe_setup_wifi, + change_password, + generate_wifi_password, + create_user, + login_available, + move_in, + migrate_user_host, + move, + edit_name, + edit_email, + edit_birthdate, + edit_person_id, + edit_address, + traffic_history, + has_balance_of_at_least, + has_positive_balance, + get_blocked_groups, + block, + unblock, + move_out, + UserStatus, + status, + generate_user_sheet, + membership_ending_task, + membership_end_date, + membership_beginning_task, + membership_begin_date, + format_user_mail, + user_send_mails, + user_send_mail, + get_active_users, + group_send_mail, + send_member_request_merged_email, + send_confirmation_email, + LoginTakenException, + EmailTakenException, + UserExistsInRoomException, + UserExistsException, + NoTenancyForRoomException, + MoveInDateInvalidException, + get_similar_users_in_room, + check_similar_user_in_room, + get_user_by_swdd_person_id, + check_new_user_data, + check_new_user_data_unused, + create_member_request, + finish_member_request, + user_from_pre_member, + confirm_mail_address, + get_member_requests, + get_name_from_first_last, + delete_member_request, + merge_member_request, + get_possible_existing_users_for_pre_member, + get_user_by_id_or_login, + send_password_reset_mail, + change_password_from_token, + find_similar_users, + are_names_similar, ) -from pycroft.model.unix_account import UnixAccount, UnixTombstone -from pycroft.model.webstorage import WebStorage -from pycroft.task import send_mails_async - -mail_confirm_url = os.getenv('MAIL_CONFIRM_URL') -password_reset_url = os.getenv('PASSWORD_RESET_URL') - - -def encode_type1_user_id(user_id: int) -> str: - """Append a type-1 error detection code to the user_id.""" - return f"{user_id:04d}-{Type1Code.calculate(user_id):d}" - - -type1_user_id_pattern = re.compile(r"^(\d{4,})-(\d)$") - - -def decode_type1_user_id(string: str) -> tuple[str, str] | None: - """ - If a given string is a type1 user id return a (user_id, code) tuple else - return None. - - :param ustring: Type1 encoded user ID - :returns: (number, code) pair or None - """ - match = type1_user_id_pattern.match(string) - return t.cast(tuple[str, str], match.groups()) if match else None - - -def encode_type2_user_id(user_id: int) -> str: - """Append a type-2 error detection code to the user_id.""" - return f"{user_id:04d}-{Type2Code.calculate(user_id):02d}" - - -type2_user_id_pattern = re.compile(r"^(\d{4,})-(\d{2})$") - - -def decode_type2_user_id(string: str) -> tuple[str, str] | None: - """ - If a given string is a type2 user id return a (user_id, code) tuple else - return None. - - :param unicode string: Type2 encoded user ID - :returns: (number, code) pair or None - :rtype: (Integral, Integral) | None - """ - match = type2_user_id_pattern.match(string) - return t.cast(tuple[str, str], match.groups()) if match else None - - -def check_user_id(string: str) -> bool: - """ - Check if the given string is a valid user id (type1 or type2). - - :param string: Type1 or Type2 encoded user ID - :returns: True if user id was valid, otherwise False - :rtype: Boolean - """ - if not string: - return False - idsplit = string.split("-") - if len(idsplit) != 2: - return False - uid, code = idsplit - encode = encode_type2_user_id if len(code) == 2 else encode_type1_user_id - return string == encode(int(uid)) - - -class HostAliasExists(ValueError): - pass - - -def setup_ipv4_networking(host: Host) -> None: - """Add suitable ips for every interface of a host""" - subnets = get_subnets_for_room(host.room) - - for interface in host.interfaces: - ip_address, subnet = get_free_ip(subnets) - new_ip = IP(interface=interface, address=ip_address, - subnet=subnet) - session.session.add(new_ip) - - -def store_user_sheet( - new_user: bool, - wifi: bool, - user: User | None = None, - timeout: int = 15, - plain_user_password: str = None, - generation_purpose: str = "", - plain_wifi_password: str = "", -) -> WebStorage: - """Generate a user sheet and store it in the WebStorage. - - Returns the generated :class:`WebStorage ` object holding the pdf. - - :param new_user: generate page with user details - :param wifi: generate page with wifi credantials - :param user: A pycroft user. Necessary in every case - :param timeout: The lifetime in minutes - :param plain_user_password: Only necessary if ``new_user is True`` - :param plain_wifi_password: The password for wifi. Only necessary if ``wifi is True`` - :param generation_purpose: Optional - """ - - pdf_data = generate_user_sheet( - new_user, wifi, user, - plain_user_password=plain_user_password, - generation_purpose=generation_purpose, - plain_wifi_password=plain_wifi_password, - ) - - pdf_storage = WebStorage(data=pdf_data, - expiry=session.utcnow() + timedelta(minutes=timeout)) - session.session.add(pdf_storage) - - return pdf_storage - - -def get_user_sheet(sheet_id: int) -> bytes | None: - """Fetch the storage object given an id. - - If not existent, return None. - """ - WebStorage.auto_expire() - - if sheet_id is None: - return None - if (storage := session.session.get(WebStorage, sheet_id)) is None: - return None - - return storage.data - - -@with_transaction -def reset_password(user: User, processor: User) -> str: - if not can_target(user, processor): - raise PermissionError("cannot reset password of a user with a" - " greater or equal permission level.") - - plain_password = user_helper.generate_password(12) - user.password = plain_password - - message = deferred_gettext("Password was reset") - log_user_event(author=processor, - user=user, - message=message.to_json()) - - return plain_password - -def can_target(user: User, processor: User) -> bool: - if user != processor: - return user.permission_level < processor.permission_level - else: - return True - - -@with_transaction -def reset_wifi_password(user: User, processor: User) -> str: - plain_password = generate_wifi_password() - user.wifi_password = plain_password - - message = deferred_gettext("WIFI-Password was reset") - log_user_event(author=processor, - user=user, - message=message.to_json()) - - return plain_password - - -def maybe_setup_wifi(user: User, processor: User) -> str | None: - """If wifi is available, sets a wifi password.""" - if user.room and user.room.building.wifi_available: - return reset_wifi_password(user, processor) - return None - - -@with_transaction -def change_password(user: User, password: str) -> None: - # TODO: verify password complexity - user.password = password - - message = deferred_gettext("Password was changed") - log_user_event(author=user, - user=user, - message=message.to_json()) - - -def generate_wifi_password() -> str: - return user_helper.generate_password(12) - - -def create_user( - name: str, login: str, email: str, birthdate: date, - groups: t.Iterable[PropertyGroup], processor: User | None, address: Address, - passwd_hash: str = None, - send_confirm_mail: bool = False -) -> tuple[User, str]: - """Create a new member - - Create a new user with a generated password, finance- and unix account, and make him member - of the `config.member_group` and `config.network_access_group`. - - :param name: The full name of the user (e.g. Max Mustermann) - :param login: The unix login for the user - :param email: E-Mail address of the user - :param birthdate: Date of birth - :param groups: The initial groups of the new user - :param processor: The processor - :param address: Where the user lives. May or may not come from a room. - :param passwd_hash: Use password hash instead of generating a new password - :param send_confirm_mail: If a confirmation mail should be send to the user - :return: - - :raises LoginTakenException: if the login is used or has been used in the past - """ - - now = session.utcnow() - - if not login_available(login, session.session): - raise LoginTakenException(login) - - plain_password: str | None = user_helper.generate_password(12) - # create a new user - new_user = User( - login=login, - name=name, - email=email, - registered_at=now, - account=Account(name="", type="USER_ASSET"), - password=plain_password, - wifi_password=generate_wifi_password(), - birthdate=birthdate, - address=address - ) - - processor = processor if processor is not None else new_user - - if passwd_hash: - new_user.passwd_hash = passwd_hash - plain_password = None - - account = UnixAccount(home_directory=f"/home/{login}") - new_user.unix_account = account - - with session.session.begin_nested(): - session.session.add(new_user) - session.session.add(account) - new_user.account.name = deferred_gettext("User {id}").format( - id=new_user.id).to_json() - - for group in groups: - make_member_of(new_user, group, processor, closed(now, None)) - - log_user_event(author=processor, - message=deferred_gettext("User created.").to_json(), - user=new_user) - - user_send_mail(new_user, UserCreatedTemplate(), True) - - if email is not None and send_confirm_mail: - send_confirmation_email(new_user) - - return new_user, plain_password - - -def login_available(login: str, session: Session) -> bool: - """Check whether there is a tombstone with the hash of the given login""" - hash = login_hash(login) - stmt = select( - ~exists( - select() - .select_from(UnixTombstone) - .filter(UnixTombstone.login_hash == hash) - .add_columns(1) - ) - ) - return session.scalar(stmt) - - -@with_transaction -def move_in( - user: User, - building_id: int, level: int, room_number: str, - mac: str | None, - processor: User | None = None, - birthdate: date = None, - host_annex: bool = False, - begin_membership: bool = True, - when: DateTimeTz | None = None, -) -> User | UserTask: - """Move in a user in a given room and do some initialization. - - The user is given a new Host with an interface of the given mac, - a finance Account, and is made member of important groups. - Networking is set up. - - Preconditions - ~~~~~~~~~~~~~ - - - User has a unix account. - - :param user: The user to move in - :param building_id: - :param level: - :param room_number: - :param mac: The mac address of the users pc. - :param processor: - :param birthdate: Date of birth - :param host_annex: when true: if MAC already in use, - annex host to new user - :param begin_membership: Starts a membership if true - :param when: The date at which the user should be moved in - - :return: The user object. - """ - - if when and when > session.utcnow(): - task_params = UserMoveInParams( - building_id=building_id, level=level, room_number=room_number, - mac=mac, birthdate=birthdate, - host_annex=host_annex, begin_membership=begin_membership - ) - return schedule_user_task(task_type=TaskType.USER_MOVE_IN, - due=when, - user=user, - parameters=task_params, - processor=processor) - if user.room is not None: - raise ValueError("user is already living in a room.") - - room = get_room(building_id, level, room_number) - - if birthdate: - user.birthdate = birthdate - - if begin_membership: - for group in {config.external_group, config.pre_member_group}: - if user.member_of(group): - remove_member_of( - user, group, processor, starting_from(session.utcnow()) - ) - - for group in {config.member_group, config.network_access_group}: - if not user.member_of(group): - make_member_of(user, group, processor, closed(session.utcnow(), None)) - - if room: - user.room = room - user.address = room.address - - if mac and user.birthdate: - interface_existing = Interface.q.filter_by(mac=mac).first() - - if interface_existing is not None: - if host_annex: - host_existing = interface_existing.host - host_existing.owner_id = user.id - - session.session.add(host_existing) - migrate_user_host(host_existing, user.room, processor) - else: - raise MacExistsException - else: - new_host = Host(owner=user, room=room) - session.session.add(new_host) - session.session.add(Interface(mac=mac, host=new_host)) - setup_ipv4_networking(new_host) - - user_send_mail(user, UserMovedInTemplate(), True) - - msg = deferred_gettext("Moved in: {room}") - - log_user_event(author=processor if processor is not None else user, - message=msg.format(room=room.short_name).to_json(), - user=user) - - return user - - -def migrate_user_host(host: Host, new_room: Room, processor: User) -> None: - """ - Migrate a UserHost to a new room and if necessary to a new subnet. - If the host changes subnet, it will get a new IP address. - - :param host: Host to be migrated - :param new_room: new room of the host - :param processor: User processing the migration - :return: - """ - old_room = host.room - host.room = new_room - - subnets_old = get_subnets_for_room(old_room) - subnets = get_subnets_for_room(new_room) - - if subnets_old != subnets: - for interface in host.interfaces: - old_ips = tuple(ip for ip in interface.ips) - for old_ip in old_ips: - ip_address, subnet = get_free_ip(subnets) - new_ip = IP(interface=interface, address=ip_address, subnet=subnet) - session.session.add(new_ip) - - old_address = old_ip.address - session.session.delete(old_ip) - - message = deferred_gettext("Changed IP of {mac} from {old_ip} to {new_ip}.").format( - old_ip=str(old_address), new_ip=str(new_ip.address), mac=interface.mac) - log_user_event(author=processor, user=host.owner, - message=message.to_json()) - - message = ( - deferred_gettext("Moved host '{name}' from {room_old} to {room_new}.") - .format( - name=host.name, room_old=old_room.short_name, room_new=new_room.short_name - ) - ) - - log_user_event(author=processor, - user=host.owner, - message=message.to_json()) - - -#TODO ensure serializability -def move( - user: User, - building_id: int, - level: int, - room_number: str, - processor: User, - comment: str | None = None, - when: DateTimeTz | None = None, -) -> User | UserTask: - """Moves the user into another room. - - :param user: The user to be moved. - :param building_id: The id of the building. - :param level: The level of the new room. - :param room_number: The number of the new room. - :param processor: The user initiating this process. Becomes author of the log message. - Not used if execution is deferred! - :param comment: a comment to be included in the log message. - :param when: The date at which the user should be moved - - :return: The user object of the moved user. - """ - - if when and when > session.utcnow(): - task_params = UserMoveParams( - building_id=building_id, level=level, room_number=room_number, - comment=comment - ) - return schedule_user_task(task_type=TaskType.USER_MOVE, - due=when, - user=user, - parameters=task_params, - processor=processor) - - old_room = user.room - had_custom_address = user.has_custom_address - new_room = Room.q.filter_by( - number=room_number, - level=level, - building_id=building_id - ).one() - - assert old_room != new_room,\ - "A User is only allowed to move in a different room!" - - user.room = new_room - if not had_custom_address: - user.address = new_room.address - - args = {'old_room': str(old_room), 'new_room': str(new_room)} - if comment: - message = deferred_gettext("Moved from {old_room} to {new_room}.\n" - "Comment: {comment}") - args.update(comment=comment) - else: - message = deferred_gettext("Moved from {old_room} to {new_room}.") - - log_user_event( - author=processor, - message=message.format(**args).to_json(), - user=user - ) - - for user_host in user.hosts: - if user_host.room == old_room: - migrate_user_host(user_host, new_room, processor) - - user_send_mail(user, UserMovedInTemplate(), True) - - return user - - -@with_transaction -def edit_name(user: User, name: str, processor: User) -> User: - """Changes the name of the user and creates a log entry. - - :param user: The user object. - :param name: The new full name. - :return: The changed user object. - """ - - if not name: - raise ValueError() - - if name == user.name: - # name wasn't changed, do nothing - return user - - old_name = user.name - user.name = name - message = deferred_gettext("Changed name from {} to {}.") - log_user_event(author=processor, user=user, - message=message.format(old_name, name).to_json()) - return user - - -@with_transaction -def edit_email( - user: User, - email: str | None, - email_forwarded: bool, - processor: User, - is_confirmed: bool = False, -) -> User: - """ - Changes the email address of a user and creates a log entry. - - :param user: User object to change - :param email: New email address (empty interpreted as ``None``) - :param email_forwarded: Boolean if emails should be forwarded - :param processor: User object of the processor, which issues the change - :param is_confirmed: If the email address is already confirmed - :return: Changed user object - """ - - if not can_target(user, processor): - raise PermissionError("cannot change email of a user with a" - " greater or equal permission level.") - - if not email: - email = None - else: - email = email.lower() - - if email_forwarded != user.email_forwarded: - user.email_forwarded = email_forwarded - - log_user_event(author=processor, user=user, - message=deferred_gettext("Set e-mail forwarding to {}.") - .format(email_forwarded).to_json()) - - if is_confirmed: - user.email_confirmed = True - user.email_confirmation_key = None - - if email == user.email: - # email wasn't changed, do nothing - return user - - old_email = user.email - user.email = email - - if email is not None: - if not is_confirmed: - send_confirmation_email(user) - else: - user.email_confirmed = False - user.email_confirmation_key = None - - message = deferred_gettext("Changed e-mail from {} to {}.") - log_user_event(author=processor, user=user, - message=message.format(old_email, email).to_json()) - return user - - -@with_transaction -def edit_birthdate(user: User, birthdate: date, processor: User) -> User: - """ - Changes the birthdate of a user and creates a log entry. - - :param user: User object to change - :param birthdate: New birthdate - :param processor: User object of the processor, which issues the change - :return: Changed user object - """ - - if not birthdate: - birthdate = None - - if birthdate == user.birthdate: - # birthdate wasn't changed, do nothing - return user - - old_bd = user.birthdate - user.birthdate = birthdate - message = deferred_gettext("Changed birthdate from {} to {}.") - log_user_event(author=processor, user=user, - message=message.format(old_bd, birthdate).to_json()) - return user - - -@with_transaction -def edit_person_id(user: User, person_id: int, processor: User) -> User: - """ - Changes the swdd_person_id of the user and creates a log entry. - - :param user: The user object. - :param person_id: The new person_id. - :return: The changed user object. - """ - - if person_id == user.swdd_person_id: - # name wasn't changed, do nothing - return user - - old_person_id = user.swdd_person_id - user.swdd_person_id = person_id - message = deferred_gettext("Changed tenant number from {} to {}.") - log_user_event(author=processor, user=user, - message=message.format(str(old_person_id), str(person_id)).to_json()) - - return user - - -@with_transaction -def edit_address( - user: User, - processor: User, - street: str, - number: str, - addition: str | None, - zip_code: str, - city: str | None, - state: str | None, - country: str | None, -) -> None: - """Changes the address of a user and appends a log entry. - - Should do nothing if the user already has an address. - """ - address = get_or_create_address(street, number, addition, zip_code, city, state, country) - user.address = address - log_user_event(deferred_gettext("Changed address to {address}").format(address=str(address)).to_json(), - processor, user) - - -def traffic_history( - user_id: int, - start: DateTimeTz | ColumnElement[DateTimeTz], - end: DateTimeTz | ColumnElement[DateTimeTz], -) -> list[TrafficHistoryEntry]: - result = session.session.execute( - select("*") - .select_from(func_traffic_history(user_id, start, end)) - ).fetchall() - return [TrafficHistoryEntry(**row._asdict()) for row in result] - - -def has_balance_of_at_least(user: User, amount: int) -> bool: - """Check whether the given user's balance is at least the given - amount. - - If a user does not have an account, we treat his balance as if it - were exactly zero. - - :param user: The user we are interested in. - :param amount: The amount we want to check for. - :return: True if and only if the user's balance is at least the given - amount (and False otherwise). - """ - balance = t.cast(int, user.account.balance if user.account else 0) - return balance >= amount - - -def has_positive_balance(user: User) -> bool: - """Check whether the given user's balance is (weakly) positive. - - :param user: The user we are interested in. - :return: True if and only if the user's balance is at least zero. - """ - return has_balance_of_at_least(user, 0) - - -def get_blocked_groups() -> list[PropertyGroup]: - return [config.violation_group, config.payment_in_default_group, - config.blocked_group] - - -@with_transaction -def block( - user: User, - reason: str, - processor: User, - during: Interval[DateTimeTz] = None, - violation: bool = True, -) -> User: - """Suspend a user during a given interval. - - The user is added to violation_group or blocked_group in a given - interval. A reason needs to be provided. - - :param user: The user to be suspended. - :param reason: The reason for suspending. - :param processor: The admin who suspended the user. - :param during: The interval in which the user is - suspended. If None the user will be suspendeded from now on - without an upper bound. - :param violation: If the user should be added to the violation group - - :return: The suspended user. - """ - if during is None: - during = starting_from(session.utcnow()) - - if violation: - make_member_of(user, config.violation_group, processor, during) - else: - make_member_of(user, config.blocked_group, processor, during) - - message = deferred_gettext("Suspended during {during}. Reason: {reason}.") - log_user_event(message=message.format(during=during, reason=reason) - .to_json(), author=processor, user=user) - return user - - -@with_transaction -def unblock(user: User, processor: User, when: DateTimeTz | None = None) -> User: - """Unblocks a user. - - This removes his membership of the violation, blocken and payment_in_default - group. - - Note that for unblocking, no further asynchronous action has to be - triggered, as opposed to e.g. membership termination. - - :param user: The user to be unblocked. - :param processor: The admin who unblocked the user. - :param when: The time of membership termination. Note - that in comparison to :py:func:`suspend`, you don't provide an - _interval_, but a point in time, defaulting to the current - time. Will be converted to ``starting_from(when)``. - - :return: The unblocked user. - """ - if when is None: - when = session.utcnow() - - during = starting_from(when) - for group in get_blocked_groups(): - if user.member_of(group, when=during): - remove_member_of(user=user, group=group, processor=processor, during=during) - - return user - - -@with_transaction -def move_out( - user: User, - comment: str, - processor: User, - when: DateTimeTz, - end_membership: bool = True, -) -> User | UserTask: - """Move out a user and may terminate relevant memberships. - - The user's room is set to ``None`` and all hosts are deleted. - Memberships in :py:obj:`config.member_group` and - :py:obj:`config.member_group` are terminated. A log message is - created including the number of deleted hosts. - - :param user: The user to move out. - :param comment: An optional comment - :param processor: The admin who is going to move out the user. - :param when: The time the user is going to move out. - :param end_membership: Ends membership if true - - :return: The user that moved out. - """ - if when > session.utcnow(): - task_params = UserMoveOutParams(comment=comment, end_membership=end_membership) - return schedule_user_task(task_type=TaskType.USER_MOVE_OUT, - due=when, - user=user, - parameters=task_params, - processor=processor) - - if end_membership: - for group in {config.member_group, - config.external_group, - config.network_access_group}: - if user.member_of(group): - remove_member_of(user, group, processor, starting_from(when)) - - deleted_interfaces = list() - num_hosts = 0 - for num_hosts, h in enumerate(user.hosts, 1): # noqa: B007 - if not h.switch and (h.room == user.room or end_membership): - for interface in h.interfaces: - deleted_interfaces.append(interface.mac) - - session.session.delete(h) - - message = None - - if user.room is not None: - message = "Moved out of {room}: Deleted interfaces {interfaces} of {num_hosts} hosts."\ - .format(room=user.room.short_name, - num_hosts=num_hosts, - interfaces=', '.join(deleted_interfaces)) - user.room = None - elif num_hosts: - message = "Deleted interfaces {interfaces} of {num_hosts} hosts." \ - .format(num_hosts=num_hosts, interfaces=', '.join(deleted_interfaces)) - - if message is not None: - if comment: - message += f"\nComment: {comment}" - - log_user_event( - message=deferred_gettext(message).to_json(), - author=processor, - user=user - ) - - return user - - -admin_properties = property.property_categories["Nutzerverwaltung"].keys() - - -class UserStatus(t.NamedTuple): - member: bool - traffic_exceeded: bool - network_access: bool - wifi_access: bool - account_balanced: bool - violation: bool - ldap: bool - admin: bool - - -def status(user: User) -> UserStatus: - has_interface = any(h.interfaces for h in user.hosts) - has_access = user.has_property("network_access") - return UserStatus( - member=user.has_property("member"), - traffic_exceeded=user.has_property("traffic_limit_exceeded"), - network_access=has_access and has_interface, - wifi_access=user.has_wifi_access and has_access, - account_balanced=user_has_paid(user), - violation=user.has_property("violation"), - ldap=user.has_property("ldap"), - admin=any(prop in user.current_properties for prop in admin_properties), - ) - - -def generate_user_sheet( - new_user: bool, - wifi: bool, - user: User | None = None, - plain_user_password: str | None = None, - generation_purpose: str = "", - plain_wifi_password: str = "", -) -> bytes: - """Create a new datasheet for the given user. - This usersheet can hold information about a user or about the wifi credentials of a user. - - This is a wrapper for - :py:func:`pycroft.helpers.printing.generate_user_sheet` equipping - it with the correct user id. - - This function cannot be exported to a `wrappers` module because it - depends on `encode_type2_user_id` and is required by - `(store|get)_user_sheet`, both in this module. - - :param new_user: Generate a page for a new created user - :param wifi: Generate a page with the wifi credantials - - Necessary in every case: - :param user: A pycroft user - - Only necessary if new_user=True: - :param plain_user_password: The password - - Only necessary if wifi=True: - :param generation_purpose: Optional purpose why this usersheet was printed - """ - from pycroft.helpers import printing - return generate_pdf( - new_user=new_user, - wifi=wifi, - bank_account=config.membership_fee_bank_account, - user=t.cast(printing.User, user), - user_id=encode_type2_user_id(user.id), - plain_user_password=plain_user_password, - generation_purpose=generation_purpose, - plain_wifi_password=plain_wifi_password, - ) - - -def membership_ending_task(user: User) -> UserTask: - """ - :return: Next task that will end the membership of the user - """ - - return t.cast( - UserTask, - UserTask.q.filter_by( - user_id=user.id, status=TaskStatus.OPEN, type=TaskType.USER_MOVE_OUT - ) - # Casting jsonb -> bool directly is only supported since PG v11 - .filter( - UserTask.parameters_json["end_membership"].cast(String).cast(Boolean) - ) - .order_by(UserTask.due.asc()) - .first(), - ) - - -def membership_end_date(user: User) -> date | None: - """ - :return: The due date of the task that will end the membership; None if not - existent - """ - - ending_task = membership_ending_task(user) - - end_date = None if ending_task is None else ending_task.due.date() - - return end_date - - -def membership_beginning_task(user: User) -> UserTask: - """ - :return: Next task that will end the membership of the user - """ - - return t.cast( - UserTask, - UserTask.q.filter_by( - user_id=user.id, status=TaskStatus.OPEN, type=TaskType.USER_MOVE_IN - ) - .filter(UserTask.parameters_json["begin_membership"].cast(Boolean)) - .order_by(UserTask.due.asc()) - .first(), - ) - - -def membership_begin_date(user: User) -> date | None: - """ - :return: The due date of the task that will begin a membership; None if not - existent - """ - - begin_task = membership_beginning_task(user) - - end_date = None if begin_task is None else begin_task.due.date() - - return end_date - - -def format_user_mail(user: User, text: str) -> str: - return text.format( - name=user.name, - login=user.login, - id=encode_type2_user_id(user.id), - email=user.email if user.email else '-', - email_internal=user.email_internal, - room_short=user.room.short_name - if user.room_id is not None else '-', - swdd_person_id=user.swdd_person_id - if user.swdd_person_id else '-', - ) - - -def user_send_mails( - users: t.Iterable[BaseUser], - template: MailTemplate | None = None, - soft_fail: bool = False, - use_internal: bool = True, - body_plain: str = None, - subject: str = None, - **kwargs: t.Any, -) -> None: - """ - Send a mail to a list of users - - :param users: Users who should receive the mail - :param template: The template that should be used. Can be None if body_plain is supplied. - :param soft_fail: Do not raise an exception if a user does not have an email and use_internal - is set to True - :param use_internal: If internal mail addresses can be used (@agdsn.me) - (Set to False to only send to external mail addresses) - :param body_plain: Alternative plain body if not template supplied - :param subject: Alternative subject if no template supplied - :param kwargs: kwargs that will be used during rendering the template - :return: - """ - - mails = [] - - for user in users: - if isinstance(user, User) and all((use_internal, - not (user.email_forwarded and user.email), - user.has_property('mail'))): - # Use internal email - email = user.email_internal - elif user.email: - # Use external email - email = user.email - else: - if soft_fail: - return - else: - raise ValueError("No contact email address available.") - - if template is not None: - # Template given, render... - plaintext, html = template.render(user=user, - user_id=encode_type2_user_id(user.id), - **kwargs) - subject = template.subject - else: - # No template given, use formatted body_mail instead. - if not isinstance(user, User): - raise ValueError("Plaintext email not supported for other User types.") - - html = None - plaintext = format_user_mail(user, body_plain) - - if plaintext is None or subject is None: - raise ValueError("No plain body supplied.") - - mail = Mail(to_name=user.name, - to_address=email, - subject=subject, - body_plain=plaintext, - body_html=html) - mails.append(mail) - - send_mails_async.delay(mails) - - -def user_send_mail( - user: BaseUser, - template: MailTemplate, - soft_fail: bool = False, - use_internal: bool = True, - **kwargs: t.Any, -) -> None: - user_send_mails([user], template, soft_fail, use_internal, **kwargs) - - -def get_active_users(session: Session, group: PropertyGroup) -> ScalarResult[User]: - return session.scalars( - select(User) - .join(User.current_memberships) - .where(Membership.group == group) - .distinct() - ) - - -def group_send_mail(group: PropertyGroup, subject: str, body_plain: str) -> None: - users = get_active_users(session=session.session, group=group) - user_send_mails(users, soft_fail=True, body_plain=body_plain, subject=subject) - - -def send_member_request_merged_email( - user: PreMember, merged_to: User, password_merged: bool -) -> None: - user_send_mail( - user, - MemberRequestMergedTemplate( - merged_to=merged_to, - merged_to_user_id=encode_type2_user_id(merged_to.id), - password_merged=password_merged, - ), - ) - - -@with_transaction -def send_confirmation_email(user: BaseUser) -> None: - user.email_confirmed = False - user.email_confirmation_key = generate_random_str(64) - - if not mail_confirm_url: - raise ValueError("No url specified in MAIL_CONFIRM_URL") - - user_send_mail(user, UserConfirmEmailTemplate( - email_confirm_url=mail_confirm_url.format(user.email_confirmation_key))) - - -class LoginTakenException(PycroftLibException): - def __init__(self, login: str | None = None) -> None: - msg = "Login already taken" if not login else f"Login {login!r} already taken" - super().__init__(msg) - - -class EmailTakenException(PycroftLibException): - def __init__(self) -> None: - super().__init__("E-Mail address already in use") - - -class UserExistsInRoomException(PycroftLibException): - def __init__(self) -> None: - super().__init__("A user with a similar name already lives in this room") - - -class UserExistsException(PycroftLibException): - def __init__(self) -> None: - super().__init__("This user already exists") - - -class NoTenancyForRoomException(PycroftLibException): - def __init__(self) -> None: - super().__init__("This user has no tenancy in that room") - - -class MoveInDateInvalidException(PycroftLibException): - def __init__(self) -> None: - super().__init__("The move-in date is invalid (in the past or more than 6 months in the future)") - - -def get_similar_users_in_room(name: str, room: Room, ratio: float = 0.75) -> list[User]: - """Get inhabitants of a room with a name similar to the given name. - - Eagerloading hints: - - `room.users` - """ - - if room is None: - return [] - - return [user for user in room.users if SequenceMatcher(None, name, user.name).ratio() > ratio] - - -def check_similar_user_in_room(name: str, room: Room) -> None: - """ - Raise an error if an user with a 75% name match already exists in the room - """ - - if get_similar_users_in_room(name, room): - raise UserExistsInRoomException - - -def get_user_by_swdd_person_id(swdd_person_id: int | None) -> User | None: - if swdd_person_id is None: - return None - - return typing.cast( - User | None, - User.q.filter_by(swdd_person_id=swdd_person_id).first() - ) - - -def check_new_user_data( - login: str, - email: str, - name: str, - swdd_person_id: int | None, - room: Room | None, - move_in_date: date | None, - ignore_similar_name: bool = False, -) -> None: - if room is not None and not ignore_similar_name: - check_similar_user_in_room(name, room) - - if move_in_date is not None: - utcnow = session.utcnow() - if not utcnow.date() <= move_in_date <= (utcnow + timedelta(days=180)).date(): - raise MoveInDateInvalidException - - -def check_new_user_data_unused(login: str, email: str, swdd_person_id: int) -> None: - """Check whether some user data from a member request is already used. - - :raises UserExistsException: - :raises LoginTakenException: - :raises EmailTakenException: - """ - user_swdd_person_id = get_user_by_swdd_person_id(swdd_person_id) - if user_swdd_person_id: - raise UserExistsException - - if not login_available(login, session=session.session): - raise LoginTakenException - - user_email = User.q.filter_by(email=email).first() - if user_email is not None: - raise EmailTakenException - - -@with_transaction -def create_member_request( - name: str, - email: str, - password: str, - login: str, - birthdate: date, - swdd_person_id: int | None, - room: Room | None, - move_in_date: date | None, - previous_dorm: str | None, -) -> PreMember: - check_new_user_data( - login, - email, - name, - swdd_person_id, - room, - move_in_date, - ) - if previous_dorm is None: - check_new_user_data_unused(login=login, email=email, swdd_person_id=swdd_person_id) - - if swdd_person_id is not None and room is not None: - tenancies = get_relevant_tenancies(swdd_person_id) - - rooms = [tenancy.room for tenancy in tenancies] - - if room not in rooms: - raise NoTenancyForRoomException - - mr = PreMember(name=name, email=email, swdd_person_id=swdd_person_id, - password=password, room=room, login=login, move_in_date=move_in_date, - birthdate=birthdate, registered_at=session.utcnow(), - previous_dorm=previous_dorm) - - session.session.add(mr) - session.session.flush() - - # Send confirmation mail - send_confirmation_email(mr) - - return mr - - -@with_transaction -def finish_member_request( - prm: PreMember, processor: User | None, ignore_similar_name: bool = False -) -> User: - if prm.room is None: - raise ValueError("Room is None") - - utcnow = session.utcnow() - - if prm.move_in_date is not None and prm.move_in_date < utcnow.date(): - prm.move_in_date = utcnow.date() - - check_new_user_data(prm.login, prm.email, prm.name, prm.swdd_person_id, prm.room, - prm.move_in_date, ignore_similar_name) - - user = user_from_pre_member(prm, processor=processor) - processor = processor or user - assert processor is not None - - move_in_datetime = utc.with_min_time(prm.move_in_date) - move_in( - user, - prm.room.building_id, - prm.room.level, - prm.room.number, - None, - processor, - when=move_in_datetime, - ) - - if move_in_datetime > utcnow: - make_member_of(user, config.pre_member_group, processor, closed(utcnow, None)) - - session.session.delete(prm) - - return user - - -def user_from_pre_member(pre_member: PreMember, processor: User) -> User: - user, _ = create_user( - pre_member.name, - pre_member.login, - pre_member.email, - pre_member.birthdate, - groups=[], - processor=processor, - address=pre_member.room.address, - passwd_hash=pre_member.passwd_hash, - ) - - processor = processor if processor is not None else user - - user.swdd_person_id = pre_member.swdd_person_id - user.email_confirmed = pre_member.email_confirmed - - message = deferred_gettext("Created from registration {}.").format(str(pre_member.id)).to_json() - log_user_event(message, processor, user) - return user - - -@with_transaction -def confirm_mail_address( - key: str, -) -> tuple[ - t.Literal["pre_member", "user"], - t.Literal["account_created", "request_pending"] | None, -]: - if not key: - raise ValueError("No key given") - - mr = PreMember.q.filter_by(email_confirmation_key=key).one_or_none() - user = User.q.filter_by(email_confirmation_key=key).one_or_none() - - if mr is None and user is None: - raise ValueError("Unknown confirmation key") - # else: one of {mr, user} is not None - - if user is None: - if mr.email_confirmed: - raise ValueError("E-Mail already confirmed") - - mr.email_confirmed = True - mr.email_confirmation_key = None - - reg_result: t.Literal["account_created", "request_pending"] - if mr.swdd_person_id is not None and mr.room is not None and mr.previous_dorm is None \ - and mr.is_adult: - finish_member_request(mr, None) - reg_result = 'account_created' - else: - user_send_mail(mr, MemberRequestPendingTemplate(is_adult=mr.is_adult)) - reg_result = 'request_pending' - - return 'pre_member', reg_result - elif mr is None: - user.email_confirmed = True - user.email_confirmation_key = None - - return 'user', None - else: - raise RuntimeError( - "Same mail confirmation key has been given to both a PreMember and a User" - ) - - -def get_member_requests() -> list[PreMember]: - prms = PreMember.q.order_by(PreMember.email_confirmed.desc())\ - .order_by(PreMember.registered_at.asc()).all() - - return prms - - -def get_name_from_first_last(first_name: str, last_name: str) -> str: - return f"{first_name} {last_name}" if last_name else first_name - - -@with_transaction -def delete_member_request( - prm: PreMember, reason: str | None, processor: User, inform_user: bool = True -) -> None: - - if reason is None: - reason = "Keine Begründung angegeben." - - log_event(deferred_gettext("Deleted member request {}. Reason: {}").format(prm.id, reason).to_json(), - processor) - - if inform_user: - user_send_mail(prm, MemberRequestDeniedTemplate(reason=reason), soft_fail=True) - - session.session.delete(prm) - - -@with_transaction -def merge_member_request( - user: User, - prm: PreMember, - merge_name: bool, - merge_email: bool, - merge_person_id: bool, - merge_room: bool, - merge_password: bool, - merge_birthdate: bool, - processor: User, -) -> None: - if prm.move_in_date is not None and prm.move_in_date < session.utcnow().date(): - prm.move_in_date = session.utcnow().date() - - if merge_name: - user = edit_name(user, prm.name, processor) - - if merge_email: - user = edit_email(user, prm.email, user.email_forwarded, processor, - is_confirmed=prm.email_confirmed) - - if merge_person_id: - user = edit_person_id(user, prm.swdd_person_id, processor) - - move_in_datetime = utc.with_min_time(prm.move_in_date) - - if merge_room: - if prm.room: - if user.room: - move(user, prm.room.building_id, prm.room.level, prm.room.number, - processor=processor, when=move_in_datetime) - - if not user.member_of(config.member_group): - make_member_of(user, config.member_group, processor, - closed(move_in_datetime, None)) - - if move_in_datetime > session.utcnow(): - make_member_of(user, config.pre_member_group, processor, - closed(session.utcnow(), move_in_datetime)) - else: - move_in(user, prm.room.building_id, prm.room.level, prm.room.number, - mac=None, processor=processor, when=move_in_datetime) - - if move_in_datetime > session.utcnow(): - make_member_of(user, config.pre_member_group, processor, - closed(session.utcnow(), None)) - - if merge_birthdate: - user = edit_birthdate(user, prm.birthdate, processor) - - log_msg = "Merged information from registration {}." - - if merge_password: - user.passwd_hash = prm.passwd_hash - - log_msg += " Password overridden." - else: - log_msg += " Kept old password." - - log_user_event(deferred_gettext(log_msg).format(encode_type2_user_id(prm.id)).to_json(), - processor, user) - - session.session.delete(prm) - - -def get_possible_existing_users_for_pre_member(prm: PreMember) -> set[User]: - user_swdd_person_id = get_user_by_swdd_person_id(prm.swdd_person_id) - user_login = User.q.filter_by(login=prm.login).first() - user_email = User.q.filter(func.lower(User.email) == prm.email.lower()).first() - - users_name = User.q.filter_by(name=prm.name).all() - users_similar = get_similar_users_in_room(prm.name, prm.room, 0.5) - - users = {user for user in [user_swdd_person_id, user_login, user_email] - + users_name + users_similar if user is not None} - - return users - - -def get_user_by_id_or_login(ident: str, email: str) -> User | None: - re_uid1 = r"^\d{4,6}-\d{1}$" - re_uid2 = r"^\d{4,6}-\d{2}$" - - user = User.q.filter(func.lower(User.email) == email.lower()) - - if re.match(re_uid1, ident): - if not check_user_id(ident): - return None - user_id, _ = decode_type1_user_id(ident) - user = user.filter_by(id=user_id) - elif re.match(re_uid2, ident): - if not check_user_id(ident): - return None - user_id, _ = decode_type2_user_id(ident) - user = user.filter_by(id=user_id) - elif re.match(BaseUser.login_regex, ident): - user = user.filter_by(login=ident) - else: - return None - - return t.cast(User | None, user.one_or_none()) - - -@with_transaction -def send_password_reset_mail(user: User) -> bool: - user.password_reset_token = generate_random_str(64) - - if not password_reset_url: - raise ValueError("No url specified in PASSWORD_RESET_URL") - - try: - user_send_mail(user, UserResetPasswordTemplate( - password_reset_url=password_reset_url.format(user.password_reset_token)), - use_internal=False) - except ValueError: - user.password_reset_token = None - return False - - return True - - -@with_transaction -def change_password_from_token(token: str | None, password: str) -> bool: - if token is None: - return False - - user = User.q.filter_by(password_reset_token=token).one_or_none() - - if user: - change_password(user, password) - user.password_reset_token = None - user.email_confirmed = True - - return True - else: - return False - - -def find_similar_users(name: str, room: Room, ratio: float) -> Iterable[User]: - """Given a potential user's name and a room, find users of similar name living in that room. - - :param name: The potential user's name - :param room: the room whose inhabitants to search - :param ratio: the threshold which determines which matches are included in this list. - For that, the `difflib.SequenceMatcher.ratio` must be greater than the given value. - """ - relevant_users_q = (session.session.query(User) - .join(RoomHistoryEntry) - .filter(RoomHistoryEntry.room == room)) - return [u for u in relevant_users_q if are_names_similar(name, u.name, threshold=ratio)] - -def are_names_similar(one: str, other: str, threshold: float) -> bool: - return SequenceMatcher(a=one, b=other).ratio() > threshold diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py new file mode 100644 index 000000000..e8c6cafdc --- /dev/null +++ b/pycroft/lib/user/_old.py @@ -0,0 +1,1593 @@ +# Copyright (c) 2015 The Pycroft Authors. See the AUTHORS file. +# This file is part of the Pycroft project and licensed under the terms of +# the Apache License, Version 2.0. See the LICENSE file for details. +""" +pycroft.lib.user +~~~~~~~~~~~~~~~~ + +This module contains. + +:copyright: (c) 2012 by AG DSN. +""" +import os +import re +import typing +import typing as t +from datetime import timedelta, date +from difflib import SequenceMatcher +from collections.abc import Iterable + +from sqlalchemy import exists, func, select, Boolean, String, ColumnElement, ScalarResult +from sqlalchemy.orm import Session + +from pycroft import config, property +from pycroft.helpers import user as user_helper, utc +from pycroft.helpers.errorcode import Type1Code, Type2Code +from pycroft.helpers.i18n import deferred_gettext +from pycroft.helpers.interval import closed, Interval, starting_from +from pycroft.helpers.printing import generate_user_sheet as generate_pdf +from pycroft.helpers.user import generate_random_str, login_hash +from pycroft.helpers.utc import DateTimeTz +from pycroft.lib.address import get_or_create_address +from pycroft.lib.exc import PycroftLibException +from pycroft.lib.facilities import get_room +from pycroft.lib.finance import user_has_paid +from pycroft.lib.logging import log_user_event, log_event +from pycroft.lib.mail import MailTemplate, Mail, UserConfirmEmailTemplate, \ + UserCreatedTemplate, \ + UserMovedInTemplate, MemberRequestPendingTemplate, \ + MemberRequestDeniedTemplate, \ + MemberRequestMergedTemplate, UserResetPasswordTemplate +from pycroft.lib.membership import make_member_of, remove_member_of +from pycroft.lib.net import get_free_ip, MacExistsException, \ + get_subnets_for_room +from pycroft.lib.swdd import get_relevant_tenancies +from pycroft.lib.task import schedule_user_task +from pycroft.model import session +from pycroft.model.address import Address +from pycroft.model.facilities import Room +from pycroft.model.finance import Account +from pycroft.model.host import IP, Host, Interface +from pycroft.model.session import with_transaction +from pycroft.model.task import TaskType, UserTask, TaskStatus +from pycroft.model.task_serialization import UserMoveParams, UserMoveOutParams, \ + UserMoveInParams +from pycroft.model.traffic import TrafficHistoryEntry +from pycroft.model.traffic import traffic_history as func_traffic_history +from pycroft.model.user import ( + User, + PreMember, + BaseUser, + RoomHistoryEntry, + PropertyGroup, + Membership, +) +from pycroft.model.unix_account import UnixAccount, UnixTombstone +from pycroft.model.webstorage import WebStorage +from pycroft.task import send_mails_async + +mail_confirm_url = os.getenv('MAIL_CONFIRM_URL') +password_reset_url = os.getenv('PASSWORD_RESET_URL') + + +def encode_type1_user_id(user_id: int) -> str: + """Append a type-1 error detection code to the user_id.""" + return f"{user_id:04d}-{Type1Code.calculate(user_id):d}" + + +type1_user_id_pattern = re.compile(r"^(\d{4,})-(\d)$") + + +def decode_type1_user_id(string: str) -> tuple[str, str] | None: + """ + If a given string is a type1 user id return a (user_id, code) tuple else + return None. + + :param ustring: Type1 encoded user ID + :returns: (number, code) pair or None + """ + match = type1_user_id_pattern.match(string) + return t.cast(tuple[str, str], match.groups()) if match else None + + +def encode_type2_user_id(user_id: int) -> str: + """Append a type-2 error detection code to the user_id.""" + return f"{user_id:04d}-{Type2Code.calculate(user_id):02d}" + + +type2_user_id_pattern = re.compile(r"^(\d{4,})-(\d{2})$") + + +def decode_type2_user_id(string: str) -> tuple[str, str] | None: + """ + If a given string is a type2 user id return a (user_id, code) tuple else + return None. + + :param unicode string: Type2 encoded user ID + :returns: (number, code) pair or None + :rtype: (Integral, Integral) | None + """ + match = type2_user_id_pattern.match(string) + return t.cast(tuple[str, str], match.groups()) if match else None + + +def check_user_id(string: str) -> bool: + """ + Check if the given string is a valid user id (type1 or type2). + + :param string: Type1 or Type2 encoded user ID + :returns: True if user id was valid, otherwise False + :rtype: Boolean + """ + if not string: + return False + idsplit = string.split("-") + if len(idsplit) != 2: + return False + uid, code = idsplit + encode = encode_type2_user_id if len(code) == 2 else encode_type1_user_id + return string == encode(int(uid)) + + +class HostAliasExists(ValueError): + pass + + +def setup_ipv4_networking(host: Host) -> None: + """Add suitable ips for every interface of a host""" + subnets = get_subnets_for_room(host.room) + + for interface in host.interfaces: + ip_address, subnet = get_free_ip(subnets) + new_ip = IP(interface=interface, address=ip_address, + subnet=subnet) + session.session.add(new_ip) + + +def store_user_sheet( + new_user: bool, + wifi: bool, + user: User | None = None, + timeout: int = 15, + plain_user_password: str = None, + generation_purpose: str = "", + plain_wifi_password: str = "", +) -> WebStorage: + """Generate a user sheet and store it in the WebStorage. + + Returns the generated :class:`WebStorage ` object holding the pdf. + + :param new_user: generate page with user details + :param wifi: generate page with wifi credantials + :param user: A pycroft user. Necessary in every case + :param timeout: The lifetime in minutes + :param plain_user_password: Only necessary if ``new_user is True`` + :param plain_wifi_password: The password for wifi. Only necessary if ``wifi is True`` + :param generation_purpose: Optional + """ + + pdf_data = generate_user_sheet( + new_user, wifi, user, + plain_user_password=plain_user_password, + generation_purpose=generation_purpose, + plain_wifi_password=plain_wifi_password, + ) + + pdf_storage = WebStorage(data=pdf_data, + expiry=session.utcnow() + timedelta(minutes=timeout)) + session.session.add(pdf_storage) + + return pdf_storage + + +def get_user_sheet(sheet_id: int) -> bytes | None: + """Fetch the storage object given an id. + + If not existent, return None. + """ + WebStorage.auto_expire() + + if sheet_id is None: + return None + if (storage := session.session.get(WebStorage, sheet_id)) is None: + return None + + return storage.data + + +@with_transaction +def reset_password(user: User, processor: User) -> str: + if not can_target(user, processor): + raise PermissionError("cannot reset password of a user with a" + " greater or equal permission level.") + + plain_password = user_helper.generate_password(12) + user.password = plain_password + + message = deferred_gettext("Password was reset") + log_user_event(author=processor, + user=user, + message=message.to_json()) + + return plain_password + +def can_target(user: User, processor: User) -> bool: + if user != processor: + return user.permission_level < processor.permission_level + else: + return True + + +@with_transaction +def reset_wifi_password(user: User, processor: User) -> str: + plain_password = generate_wifi_password() + user.wifi_password = plain_password + + message = deferred_gettext("WIFI-Password was reset") + log_user_event(author=processor, + user=user, + message=message.to_json()) + + return plain_password + + +def maybe_setup_wifi(user: User, processor: User) -> str | None: + """If wifi is available, sets a wifi password.""" + if user.room and user.room.building.wifi_available: + return reset_wifi_password(user, processor) + return None + + +@with_transaction +def change_password(user: User, password: str) -> None: + # TODO: verify password complexity + user.password = password + + message = deferred_gettext("Password was changed") + log_user_event(author=user, + user=user, + message=message.to_json()) + + +def generate_wifi_password() -> str: + return user_helper.generate_password(12) + + +def create_user( + name: str, login: str, email: str, birthdate: date, + groups: t.Iterable[PropertyGroup], processor: User | None, address: Address, + passwd_hash: str = None, + send_confirm_mail: bool = False +) -> tuple[User, str]: + """Create a new member + + Create a new user with a generated password, finance- and unix account, and make him member + of the `config.member_group` and `config.network_access_group`. + + :param name: The full name of the user (e.g. Max Mustermann) + :param login: The unix login for the user + :param email: E-Mail address of the user + :param birthdate: Date of birth + :param groups: The initial groups of the new user + :param processor: The processor + :param address: Where the user lives. May or may not come from a room. + :param passwd_hash: Use password hash instead of generating a new password + :param send_confirm_mail: If a confirmation mail should be send to the user + :return: + + :raises LoginTakenException: if the login is used or has been used in the past + """ + + now = session.utcnow() + + if not login_available(login, session.session): + raise LoginTakenException(login) + + plain_password: str | None = user_helper.generate_password(12) + # create a new user + new_user = User( + login=login, + name=name, + email=email, + registered_at=now, + account=Account(name="", type="USER_ASSET"), + password=plain_password, + wifi_password=generate_wifi_password(), + birthdate=birthdate, + address=address + ) + + processor = processor if processor is not None else new_user + + if passwd_hash: + new_user.passwd_hash = passwd_hash + plain_password = None + + account = UnixAccount(home_directory=f"/home/{login}") + new_user.unix_account = account + + with session.session.begin_nested(): + session.session.add(new_user) + session.session.add(account) + new_user.account.name = deferred_gettext("User {id}").format( + id=new_user.id).to_json() + + for group in groups: + make_member_of(new_user, group, processor, closed(now, None)) + + log_user_event(author=processor, + message=deferred_gettext("User created.").to_json(), + user=new_user) + + user_send_mail(new_user, UserCreatedTemplate(), True) + + if email is not None and send_confirm_mail: + send_confirmation_email(new_user) + + return new_user, plain_password + + +def login_available(login: str, session: Session) -> bool: + """Check whether there is a tombstone with the hash of the given login""" + hash = login_hash(login) + stmt = select( + ~exists( + select() + .select_from(UnixTombstone) + .filter(UnixTombstone.login_hash == hash) + .add_columns(1) + ) + ) + return session.scalar(stmt) + + +@with_transaction +def move_in( + user: User, + building_id: int, level: int, room_number: str, + mac: str | None, + processor: User | None = None, + birthdate: date = None, + host_annex: bool = False, + begin_membership: bool = True, + when: DateTimeTz | None = None, +) -> User | UserTask: + """Move in a user in a given room and do some initialization. + + The user is given a new Host with an interface of the given mac, + a finance Account, and is made member of important groups. + Networking is set up. + + Preconditions + ~~~~~~~~~~~~~ + + - User has a unix account. + + :param user: The user to move in + :param building_id: + :param level: + :param room_number: + :param mac: The mac address of the users pc. + :param processor: + :param birthdate: Date of birth + :param host_annex: when true: if MAC already in use, + annex host to new user + :param begin_membership: Starts a membership if true + :param when: The date at which the user should be moved in + + :return: The user object. + """ + + if when and when > session.utcnow(): + task_params = UserMoveInParams( + building_id=building_id, level=level, room_number=room_number, + mac=mac, birthdate=birthdate, + host_annex=host_annex, begin_membership=begin_membership + ) + return schedule_user_task(task_type=TaskType.USER_MOVE_IN, + due=when, + user=user, + parameters=task_params, + processor=processor) + if user.room is not None: + raise ValueError("user is already living in a room.") + + room = get_room(building_id, level, room_number) + + if birthdate: + user.birthdate = birthdate + + if begin_membership: + for group in {config.external_group, config.pre_member_group}: + if user.member_of(group): + remove_member_of( + user, group, processor, starting_from(session.utcnow()) + ) + + for group in {config.member_group, config.network_access_group}: + if not user.member_of(group): + make_member_of(user, group, processor, closed(session.utcnow(), None)) + + if room: + user.room = room + user.address = room.address + + if mac and user.birthdate: + interface_existing = Interface.q.filter_by(mac=mac).first() + + if interface_existing is not None: + if host_annex: + host_existing = interface_existing.host + host_existing.owner_id = user.id + + session.session.add(host_existing) + migrate_user_host(host_existing, user.room, processor) + else: + raise MacExistsException + else: + new_host = Host(owner=user, room=room) + session.session.add(new_host) + session.session.add(Interface(mac=mac, host=new_host)) + setup_ipv4_networking(new_host) + + user_send_mail(user, UserMovedInTemplate(), True) + + msg = deferred_gettext("Moved in: {room}") + + log_user_event(author=processor if processor is not None else user, + message=msg.format(room=room.short_name).to_json(), + user=user) + + return user + + +def migrate_user_host(host: Host, new_room: Room, processor: User) -> None: + """ + Migrate a UserHost to a new room and if necessary to a new subnet. + If the host changes subnet, it will get a new IP address. + + :param host: Host to be migrated + :param new_room: new room of the host + :param processor: User processing the migration + :return: + """ + old_room = host.room + host.room = new_room + + subnets_old = get_subnets_for_room(old_room) + subnets = get_subnets_for_room(new_room) + + if subnets_old != subnets: + for interface in host.interfaces: + old_ips = tuple(ip for ip in interface.ips) + for old_ip in old_ips: + ip_address, subnet = get_free_ip(subnets) + new_ip = IP(interface=interface, address=ip_address, subnet=subnet) + session.session.add(new_ip) + + old_address = old_ip.address + session.session.delete(old_ip) + + message = deferred_gettext("Changed IP of {mac} from {old_ip} to {new_ip}.").format( + old_ip=str(old_address), new_ip=str(new_ip.address), mac=interface.mac) + log_user_event(author=processor, user=host.owner, + message=message.to_json()) + + message = ( + deferred_gettext("Moved host '{name}' from {room_old} to {room_new}.") + .format( + name=host.name, room_old=old_room.short_name, room_new=new_room.short_name + ) + ) + + log_user_event(author=processor, + user=host.owner, + message=message.to_json()) + + +#TODO ensure serializability +def move( + user: User, + building_id: int, + level: int, + room_number: str, + processor: User, + comment: str | None = None, + when: DateTimeTz | None = None, +) -> User | UserTask: + """Moves the user into another room. + + :param user: The user to be moved. + :param building_id: The id of the building. + :param level: The level of the new room. + :param room_number: The number of the new room. + :param processor: The user initiating this process. Becomes author of the log message. + Not used if execution is deferred! + :param comment: a comment to be included in the log message. + :param when: The date at which the user should be moved + + :return: The user object of the moved user. + """ + + if when and when > session.utcnow(): + task_params = UserMoveParams( + building_id=building_id, level=level, room_number=room_number, + comment=comment + ) + return schedule_user_task(task_type=TaskType.USER_MOVE, + due=when, + user=user, + parameters=task_params, + processor=processor) + + old_room = user.room + had_custom_address = user.has_custom_address + new_room = Room.q.filter_by( + number=room_number, + level=level, + building_id=building_id + ).one() + + assert old_room != new_room,\ + "A User is only allowed to move in a different room!" + + user.room = new_room + if not had_custom_address: + user.address = new_room.address + + args = {'old_room': str(old_room), 'new_room': str(new_room)} + if comment: + message = deferred_gettext("Moved from {old_room} to {new_room}.\n" + "Comment: {comment}") + args.update(comment=comment) + else: + message = deferred_gettext("Moved from {old_room} to {new_room}.") + + log_user_event( + author=processor, + message=message.format(**args).to_json(), + user=user + ) + + for user_host in user.hosts: + if user_host.room == old_room: + migrate_user_host(user_host, new_room, processor) + + user_send_mail(user, UserMovedInTemplate(), True) + + return user + + +@with_transaction +def edit_name(user: User, name: str, processor: User) -> User: + """Changes the name of the user and creates a log entry. + + :param user: The user object. + :param name: The new full name. + :return: The changed user object. + """ + + if not name: + raise ValueError() + + if name == user.name: + # name wasn't changed, do nothing + return user + + old_name = user.name + user.name = name + message = deferred_gettext("Changed name from {} to {}.") + log_user_event(author=processor, user=user, + message=message.format(old_name, name).to_json()) + return user + + +@with_transaction +def edit_email( + user: User, + email: str | None, + email_forwarded: bool, + processor: User, + is_confirmed: bool = False, +) -> User: + """ + Changes the email address of a user and creates a log entry. + + :param user: User object to change + :param email: New email address (empty interpreted as ``None``) + :param email_forwarded: Boolean if emails should be forwarded + :param processor: User object of the processor, which issues the change + :param is_confirmed: If the email address is already confirmed + :return: Changed user object + """ + + if not can_target(user, processor): + raise PermissionError("cannot change email of a user with a" + " greater or equal permission level.") + + if not email: + email = None + else: + email = email.lower() + + if email_forwarded != user.email_forwarded: + user.email_forwarded = email_forwarded + + log_user_event(author=processor, user=user, + message=deferred_gettext("Set e-mail forwarding to {}.") + .format(email_forwarded).to_json()) + + if is_confirmed: + user.email_confirmed = True + user.email_confirmation_key = None + + if email == user.email: + # email wasn't changed, do nothing + return user + + old_email = user.email + user.email = email + + if email is not None: + if not is_confirmed: + send_confirmation_email(user) + else: + user.email_confirmed = False + user.email_confirmation_key = None + + message = deferred_gettext("Changed e-mail from {} to {}.") + log_user_event(author=processor, user=user, + message=message.format(old_email, email).to_json()) + return user + + +@with_transaction +def edit_birthdate(user: User, birthdate: date, processor: User) -> User: + """ + Changes the birthdate of a user and creates a log entry. + + :param user: User object to change + :param birthdate: New birthdate + :param processor: User object of the processor, which issues the change + :return: Changed user object + """ + + if not birthdate: + birthdate = None + + if birthdate == user.birthdate: + # birthdate wasn't changed, do nothing + return user + + old_bd = user.birthdate + user.birthdate = birthdate + message = deferred_gettext("Changed birthdate from {} to {}.") + log_user_event(author=processor, user=user, + message=message.format(old_bd, birthdate).to_json()) + return user + + +@with_transaction +def edit_person_id(user: User, person_id: int, processor: User) -> User: + """ + Changes the swdd_person_id of the user and creates a log entry. + + :param user: The user object. + :param person_id: The new person_id. + :return: The changed user object. + """ + + if person_id == user.swdd_person_id: + # name wasn't changed, do nothing + return user + + old_person_id = user.swdd_person_id + user.swdd_person_id = person_id + message = deferred_gettext("Changed tenant number from {} to {}.") + log_user_event(author=processor, user=user, + message=message.format(str(old_person_id), str(person_id)).to_json()) + + return user + + +@with_transaction +def edit_address( + user: User, + processor: User, + street: str, + number: str, + addition: str | None, + zip_code: str, + city: str | None, + state: str | None, + country: str | None, +) -> None: + """Changes the address of a user and appends a log entry. + + Should do nothing if the user already has an address. + """ + address = get_or_create_address(street, number, addition, zip_code, city, state, country) + user.address = address + log_user_event(deferred_gettext("Changed address to {address}").format(address=str(address)).to_json(), + processor, user) + + +def traffic_history( + user_id: int, + start: DateTimeTz | ColumnElement[DateTimeTz], + end: DateTimeTz | ColumnElement[DateTimeTz], +) -> list[TrafficHistoryEntry]: + result = session.session.execute( + select("*") + .select_from(func_traffic_history(user_id, start, end)) + ).fetchall() + return [TrafficHistoryEntry(**row._asdict()) for row in result] + + +def has_balance_of_at_least(user: User, amount: int) -> bool: + """Check whether the given user's balance is at least the given + amount. + + If a user does not have an account, we treat his balance as if it + were exactly zero. + + :param user: The user we are interested in. + :param amount: The amount we want to check for. + :return: True if and only if the user's balance is at least the given + amount (and False otherwise). + """ + balance = t.cast(int, user.account.balance if user.account else 0) + return balance >= amount + + +def has_positive_balance(user: User) -> bool: + """Check whether the given user's balance is (weakly) positive. + + :param user: The user we are interested in. + :return: True if and only if the user's balance is at least zero. + """ + return has_balance_of_at_least(user, 0) + + +def get_blocked_groups() -> list[PropertyGroup]: + return [config.violation_group, config.payment_in_default_group, + config.blocked_group] + + +@with_transaction +def block( + user: User, + reason: str, + processor: User, + during: Interval[DateTimeTz] = None, + violation: bool = True, +) -> User: + """Suspend a user during a given interval. + + The user is added to violation_group or blocked_group in a given + interval. A reason needs to be provided. + + :param user: The user to be suspended. + :param reason: The reason for suspending. + :param processor: The admin who suspended the user. + :param during: The interval in which the user is + suspended. If None the user will be suspendeded from now on + without an upper bound. + :param violation: If the user should be added to the violation group + + :return: The suspended user. + """ + if during is None: + during = starting_from(session.utcnow()) + + if violation: + make_member_of(user, config.violation_group, processor, during) + else: + make_member_of(user, config.blocked_group, processor, during) + + message = deferred_gettext("Suspended during {during}. Reason: {reason}.") + log_user_event(message=message.format(during=during, reason=reason) + .to_json(), author=processor, user=user) + return user + + +@with_transaction +def unblock(user: User, processor: User, when: DateTimeTz | None = None) -> User: + """Unblocks a user. + + This removes his membership of the violation, blocken and payment_in_default + group. + + Note that for unblocking, no further asynchronous action has to be + triggered, as opposed to e.g. membership termination. + + :param user: The user to be unblocked. + :param processor: The admin who unblocked the user. + :param when: The time of membership termination. Note + that in comparison to :py:func:`suspend`, you don't provide an + _interval_, but a point in time, defaulting to the current + time. Will be converted to ``starting_from(when)``. + + :return: The unblocked user. + """ + if when is None: + when = session.utcnow() + + during = starting_from(when) + for group in get_blocked_groups(): + if user.member_of(group, when=during): + remove_member_of(user=user, group=group, processor=processor, during=during) + + return user + + +@with_transaction +def move_out( + user: User, + comment: str, + processor: User, + when: DateTimeTz, + end_membership: bool = True, +) -> User | UserTask: + """Move out a user and may terminate relevant memberships. + + The user's room is set to ``None`` and all hosts are deleted. + Memberships in :py:obj:`config.member_group` and + :py:obj:`config.member_group` are terminated. A log message is + created including the number of deleted hosts. + + :param user: The user to move out. + :param comment: An optional comment + :param processor: The admin who is going to move out the user. + :param when: The time the user is going to move out. + :param end_membership: Ends membership if true + + :return: The user that moved out. + """ + if when > session.utcnow(): + task_params = UserMoveOutParams(comment=comment, end_membership=end_membership) + return schedule_user_task(task_type=TaskType.USER_MOVE_OUT, + due=when, + user=user, + parameters=task_params, + processor=processor) + + if end_membership: + for group in {config.member_group, + config.external_group, + config.network_access_group}: + if user.member_of(group): + remove_member_of(user, group, processor, starting_from(when)) + + deleted_interfaces = list() + num_hosts = 0 + for num_hosts, h in enumerate(user.hosts, 1): # noqa: B007 + if not h.switch and (h.room == user.room or end_membership): + for interface in h.interfaces: + deleted_interfaces.append(interface.mac) + + session.session.delete(h) + + message = None + + if user.room is not None: + message = "Moved out of {room}: Deleted interfaces {interfaces} of {num_hosts} hosts."\ + .format(room=user.room.short_name, + num_hosts=num_hosts, + interfaces=', '.join(deleted_interfaces)) + user.room = None + elif num_hosts: + message = "Deleted interfaces {interfaces} of {num_hosts} hosts." \ + .format(num_hosts=num_hosts, interfaces=', '.join(deleted_interfaces)) + + if message is not None: + if comment: + message += f"\nComment: {comment}" + + log_user_event( + message=deferred_gettext(message).to_json(), + author=processor, + user=user + ) + + return user + + +admin_properties = property.property_categories["Nutzerverwaltung"].keys() + + +class UserStatus(t.NamedTuple): + member: bool + traffic_exceeded: bool + network_access: bool + wifi_access: bool + account_balanced: bool + violation: bool + ldap: bool + admin: bool + + +def status(user: User) -> UserStatus: + has_interface = any(h.interfaces for h in user.hosts) + has_access = user.has_property("network_access") + return UserStatus( + member=user.has_property("member"), + traffic_exceeded=user.has_property("traffic_limit_exceeded"), + network_access=has_access and has_interface, + wifi_access=user.has_wifi_access and has_access, + account_balanced=user_has_paid(user), + violation=user.has_property("violation"), + ldap=user.has_property("ldap"), + admin=any(prop in user.current_properties for prop in admin_properties), + ) + + +def generate_user_sheet( + new_user: bool, + wifi: bool, + user: User | None = None, + plain_user_password: str | None = None, + generation_purpose: str = "", + plain_wifi_password: str = "", +) -> bytes: + """Create a new datasheet for the given user. + This usersheet can hold information about a user or about the wifi credentials of a user. + + This is a wrapper for + :py:func:`pycroft.helpers.printing.generate_user_sheet` equipping + it with the correct user id. + + This function cannot be exported to a `wrappers` module because it + depends on `encode_type2_user_id` and is required by + `(store|get)_user_sheet`, both in this module. + + :param new_user: Generate a page for a new created user + :param wifi: Generate a page with the wifi credantials + + Necessary in every case: + :param user: A pycroft user + + Only necessary if new_user=True: + :param plain_user_password: The password + + Only necessary if wifi=True: + :param generation_purpose: Optional purpose why this usersheet was printed + """ + from pycroft.helpers import printing + return generate_pdf( + new_user=new_user, + wifi=wifi, + bank_account=config.membership_fee_bank_account, + user=t.cast(printing.User, user), + user_id=encode_type2_user_id(user.id), + plain_user_password=plain_user_password, + generation_purpose=generation_purpose, + plain_wifi_password=plain_wifi_password, + ) + + +def membership_ending_task(user: User) -> UserTask: + """ + :return: Next task that will end the membership of the user + """ + + return t.cast( + UserTask, + UserTask.q.filter_by( + user_id=user.id, status=TaskStatus.OPEN, type=TaskType.USER_MOVE_OUT + ) + # Casting jsonb -> bool directly is only supported since PG v11 + .filter( + UserTask.parameters_json["end_membership"].cast(String).cast(Boolean) + ) + .order_by(UserTask.due.asc()) + .first(), + ) + + +def membership_end_date(user: User) -> date | None: + """ + :return: The due date of the task that will end the membership; None if not + existent + """ + + ending_task = membership_ending_task(user) + + end_date = None if ending_task is None else ending_task.due.date() + + return end_date + + +def membership_beginning_task(user: User) -> UserTask: + """ + :return: Next task that will end the membership of the user + """ + + return t.cast( + UserTask, + UserTask.q.filter_by( + user_id=user.id, status=TaskStatus.OPEN, type=TaskType.USER_MOVE_IN + ) + .filter(UserTask.parameters_json["begin_membership"].cast(Boolean)) + .order_by(UserTask.due.asc()) + .first(), + ) + + +def membership_begin_date(user: User) -> date | None: + """ + :return: The due date of the task that will begin a membership; None if not + existent + """ + + begin_task = membership_beginning_task(user) + + end_date = None if begin_task is None else begin_task.due.date() + + return end_date + + +def format_user_mail(user: User, text: str) -> str: + return text.format( + name=user.name, + login=user.login, + id=encode_type2_user_id(user.id), + email=user.email if user.email else '-', + email_internal=user.email_internal, + room_short=user.room.short_name + if user.room_id is not None else '-', + swdd_person_id=user.swdd_person_id + if user.swdd_person_id else '-', + ) + + +def user_send_mails( + users: t.Iterable[BaseUser], + template: MailTemplate | None = None, + soft_fail: bool = False, + use_internal: bool = True, + body_plain: str = None, + subject: str = None, + **kwargs: t.Any, +) -> None: + """ + Send a mail to a list of users + + :param users: Users who should receive the mail + :param template: The template that should be used. Can be None if body_plain is supplied. + :param soft_fail: Do not raise an exception if a user does not have an email and use_internal + is set to True + :param use_internal: If internal mail addresses can be used (@agdsn.me) + (Set to False to only send to external mail addresses) + :param body_plain: Alternative plain body if not template supplied + :param subject: Alternative subject if no template supplied + :param kwargs: kwargs that will be used during rendering the template + :return: + """ + + mails = [] + + for user in users: + if isinstance(user, User) and all((use_internal, + not (user.email_forwarded and user.email), + user.has_property('mail'))): + # Use internal email + email = user.email_internal + elif user.email: + # Use external email + email = user.email + else: + if soft_fail: + return + else: + raise ValueError("No contact email address available.") + + if template is not None: + # Template given, render... + plaintext, html = template.render(user=user, + user_id=encode_type2_user_id(user.id), + **kwargs) + subject = template.subject + else: + # No template given, use formatted body_mail instead. + if not isinstance(user, User): + raise ValueError("Plaintext email not supported for other User types.") + + html = None + plaintext = format_user_mail(user, body_plain) + + if plaintext is None or subject is None: + raise ValueError("No plain body supplied.") + + mail = Mail(to_name=user.name, + to_address=email, + subject=subject, + body_plain=plaintext, + body_html=html) + mails.append(mail) + + send_mails_async.delay(mails) + + +def user_send_mail( + user: BaseUser, + template: MailTemplate, + soft_fail: bool = False, + use_internal: bool = True, + **kwargs: t.Any, +) -> None: + user_send_mails([user], template, soft_fail, use_internal, **kwargs) + + +def get_active_users(session: Session, group: PropertyGroup) -> ScalarResult[User]: + return session.scalars( + select(User) + .join(User.current_memberships) + .where(Membership.group == group) + .distinct() + ) + + +def group_send_mail(group: PropertyGroup, subject: str, body_plain: str) -> None: + users = get_active_users(session=session.session, group=group) + user_send_mails(users, soft_fail=True, body_plain=body_plain, subject=subject) + + +def send_member_request_merged_email( + user: PreMember, merged_to: User, password_merged: bool +) -> None: + user_send_mail( + user, + MemberRequestMergedTemplate( + merged_to=merged_to, + merged_to_user_id=encode_type2_user_id(merged_to.id), + password_merged=password_merged, + ), + ) + + +@with_transaction +def send_confirmation_email(user: BaseUser) -> None: + user.email_confirmed = False + user.email_confirmation_key = generate_random_str(64) + + if not mail_confirm_url: + raise ValueError("No url specified in MAIL_CONFIRM_URL") + + user_send_mail(user, UserConfirmEmailTemplate( + email_confirm_url=mail_confirm_url.format(user.email_confirmation_key))) + + +class LoginTakenException(PycroftLibException): + def __init__(self, login: str | None = None) -> None: + msg = "Login already taken" if not login else f"Login {login!r} already taken" + super().__init__(msg) + + +class EmailTakenException(PycroftLibException): + def __init__(self) -> None: + super().__init__("E-Mail address already in use") + + +class UserExistsInRoomException(PycroftLibException): + def __init__(self) -> None: + super().__init__("A user with a similar name already lives in this room") + + +class UserExistsException(PycroftLibException): + def __init__(self) -> None: + super().__init__("This user already exists") + + +class NoTenancyForRoomException(PycroftLibException): + def __init__(self) -> None: + super().__init__("This user has no tenancy in that room") + + +class MoveInDateInvalidException(PycroftLibException): + def __init__(self) -> None: + super().__init__("The move-in date is invalid (in the past or more than 6 months in the future)") + + +def get_similar_users_in_room(name: str, room: Room, ratio: float = 0.75) -> list[User]: + """Get inhabitants of a room with a name similar to the given name. + + Eagerloading hints: + - `room.users` + """ + + if room is None: + return [] + + return [user for user in room.users if SequenceMatcher(None, name, user.name).ratio() > ratio] + + +def check_similar_user_in_room(name: str, room: Room) -> None: + """ + Raise an error if an user with a 75% name match already exists in the room + """ + + if get_similar_users_in_room(name, room): + raise UserExistsInRoomException + + +def get_user_by_swdd_person_id(swdd_person_id: int | None) -> User | None: + if swdd_person_id is None: + return None + + return typing.cast( + User | None, + User.q.filter_by(swdd_person_id=swdd_person_id).first() + ) + + +def check_new_user_data( + login: str, + email: str, + name: str, + swdd_person_id: int | None, + room: Room | None, + move_in_date: date | None, + ignore_similar_name: bool = False, +) -> None: + if room is not None and not ignore_similar_name: + check_similar_user_in_room(name, room) + + if move_in_date is not None: + utcnow = session.utcnow() + if not utcnow.date() <= move_in_date <= (utcnow + timedelta(days=180)).date(): + raise MoveInDateInvalidException + + +def check_new_user_data_unused(login: str, email: str, swdd_person_id: int) -> None: + """Check whether some user data from a member request is already used. + + :raises UserExistsException: + :raises LoginTakenException: + :raises EmailTakenException: + """ + user_swdd_person_id = get_user_by_swdd_person_id(swdd_person_id) + if user_swdd_person_id: + raise UserExistsException + + if not login_available(login, session=session.session): + raise LoginTakenException + + user_email = User.q.filter_by(email=email).first() + if user_email is not None: + raise EmailTakenException + + +@with_transaction +def create_member_request( + name: str, + email: str, + password: str, + login: str, + birthdate: date, + swdd_person_id: int | None, + room: Room | None, + move_in_date: date | None, + previous_dorm: str | None, +) -> PreMember: + check_new_user_data( + login, + email, + name, + swdd_person_id, + room, + move_in_date, + ) + if previous_dorm is None: + check_new_user_data_unused(login=login, mail=email, swdd_person_id=swdd_person_id) + + if swdd_person_id is not None and room is not None: + tenancies = get_relevant_tenancies(swdd_person_id) + + rooms = [tenancy.room for tenancy in tenancies] + + if room not in rooms: + raise NoTenancyForRoomException + + mr = PreMember(name=name, email=email, swdd_person_id=swdd_person_id, + password=password, room=room, login=login, move_in_date=move_in_date, + birthdate=birthdate, registered_at=session.utcnow(), + previous_dorm=previous_dorm) + + session.session.add(mr) + session.session.flush() + + # Send confirmation mail + send_confirmation_email(mr) + + return mr + + +@with_transaction +def finish_member_request( + prm: PreMember, processor: User | None, ignore_similar_name: bool = False +) -> User: + if prm.room is None: + raise ValueError("Room is None") + + utcnow = session.utcnow() + + if prm.move_in_date is not None and prm.move_in_date < utcnow.date(): + prm.move_in_date = utcnow.date() + + check_new_user_data(prm.login, prm.email, prm.name, prm.swdd_person_id, prm.room, + prm.move_in_date, ignore_similar_name) + + user = user_from_pre_member(prm, processor=processor) + processor = processor or user + assert processor is not None + + move_in_datetime = utc.with_min_time(prm.move_in_date) + move_in( + user, + prm.room.building_id, + prm.room.level, + prm.room.number, + None, + processor, + when=move_in_datetime, + ) + + if move_in_datetime > utcnow: + make_member_of(user, config.pre_member_group, processor, closed(utcnow, None)) + + session.session.delete(prm) + + return user + + +def user_from_pre_member(pre_member: PreMember, processor: User) -> User: + user, _ = create_user( + pre_member.name, + pre_member.login, + pre_member.email, + pre_member.birthdate, + groups=[], + processor=processor, + address=pre_member.room.address, + passwd_hash=pre_member.passwd_hash, + ) + + processor = processor if processor is not None else user + + user.swdd_person_id = pre_member.swdd_person_id + user.email_confirmed = pre_member.email_confirmed + + message = deferred_gettext("Created from registration {}.").format(str(pre_member.id)).to_json() + log_user_event(message, processor, user) + + + +@with_transaction +def confirm_mail_address( + key: str, +) -> tuple[ + t.Literal["pre_member", "user"], + t.Literal["account_created", "request_pending"] | None, +]: + if not key: + raise ValueError("No key given") + + mr = PreMember.q.filter_by(email_confirmation_key=key).one_or_none() + user = User.q.filter_by(email_confirmation_key=key).one_or_none() + + if mr is None and user is None: + raise ValueError("Unknown confirmation key") + # else: one of {mr, user} is not None + + if user is None: + if mr.email_confirmed: + raise ValueError("E-Mail already confirmed") + + mr.email_confirmed = True + mr.email_confirmation_key = None + + reg_result: t.Literal["account_created", "request_pending"] + if mr.swdd_person_id is not None and mr.room is not None and mr.previous_dorm is None \ + and mr.is_adult: + finish_member_request(mr, None) + reg_result = 'account_created' + else: + user_send_mail(mr, MemberRequestPendingTemplate(is_adult=mr.is_adult)) + reg_result = 'request_pending' + + return 'pre_member', reg_result + elif mr is None: + user.email_confirmed = True + user.email_confirmation_key = None + + return 'user', None + else: + raise RuntimeError( + "Same mail confirmation key has been given to both a PreMember and a User" + ) + + +def get_member_requests() -> list[PreMember]: + prms = PreMember.q.order_by(PreMember.email_confirmed.desc())\ + .order_by(PreMember.registered_at.asc()).all() + + return prms + + +def get_name_from_first_last(first_name: str, last_name: str) -> str: + return f"{first_name} {last_name}" if last_name else first_name + + +@with_transaction +def delete_member_request( + prm: PreMember, reason: str | None, processor: User, inform_user: bool = True +) -> None: + + if reason is None: + reason = "Keine Begründung angegeben." + + log_event(deferred_gettext("Deleted member request {}. Reason: {}").format(prm.id, reason).to_json(), + processor) + + if inform_user: + user_send_mail(prm, MemberRequestDeniedTemplate(reason=reason), soft_fail=True) + + session.session.delete(prm) + + +@with_transaction +def merge_member_request( + user: User, + prm: PreMember, + merge_name: bool, + merge_email: bool, + merge_person_id: bool, + merge_room: bool, + merge_password: bool, + merge_birthdate: bool, + processor: User, +) -> None: + if prm.move_in_date is not None and prm.move_in_date < session.utcnow().date(): + prm.move_in_date = session.utcnow().date() + + if merge_name: + user = edit_name(user, prm.name, processor) + + if merge_email: + user = edit_email(user, prm.email, user.email_forwarded, processor, + is_confirmed=prm.email_confirmed) + + if merge_person_id: + user = edit_person_id(user, prm.swdd_person_id, processor) + + move_in_datetime = utc.with_min_time(prm.move_in_date) + + if merge_room: + if prm.room: + if user.room: + move(user, prm.room.building_id, prm.room.level, prm.room.number, + processor=processor, when=move_in_datetime) + + if not user.member_of(config.member_group): + make_member_of(user, config.member_group, processor, + closed(move_in_datetime, None)) + + if move_in_datetime > session.utcnow(): + make_member_of(user, config.pre_member_group, processor, + closed(session.utcnow(), move_in_datetime)) + else: + move_in(user, prm.room.building_id, prm.room.level, prm.room.number, + mac=None, processor=processor, when=move_in_datetime) + + if move_in_datetime > session.utcnow(): + make_member_of(user, config.pre_member_group, processor, + closed(session.utcnow(), None)) + + if merge_birthdate: + user = edit_birthdate(user, prm.birthdate, processor) + + log_msg = "Merged information from registration {}." + + if merge_password: + user.passwd_hash = prm.passwd_hash + + log_msg += " Password overridden." + else: + log_msg += " Kept old password." + + log_user_event(deferred_gettext(log_msg).format(encode_type2_user_id(prm.id)).to_json(), + processor, user) + + session.session.delete(prm) + + +def get_possible_existing_users_for_pre_member(prm: PreMember) -> set[User]: + user_swdd_person_id = get_user_by_swdd_person_id(prm.swdd_person_id) + user_login = User.q.filter_by(login=prm.login).first() + user_email = User.q.filter(func.lower(User.email) == prm.email.lower()).first() + + users_name = User.q.filter_by(name=prm.name).all() + users_similar = get_similar_users_in_room(prm.name, prm.room, 0.5) + + users = {user for user in [user_swdd_person_id, user_login, user_email] + + users_name + users_similar if user is not None} + + return users + + +def get_user_by_id_or_login(ident: str, email: str) -> User | None: + re_uid1 = r"^\d{4,6}-\d{1}$" + re_uid2 = r"^\d{4,6}-\d{2}$" + + user = User.q.filter(func.lower(User.email) == email.lower()) + + if re.match(re_uid1, ident): + if not check_user_id(ident): + return None + user_id, _ = decode_type1_user_id(ident) + user = user.filter_by(id=user_id) + elif re.match(re_uid2, ident): + if not check_user_id(ident): + return None + user_id, _ = decode_type2_user_id(ident) + user = user.filter_by(id=user_id) + elif re.match(BaseUser.login_regex, ident): + user = user.filter_by(login=ident) + else: + return None + + return t.cast(User | None, user.one_or_none()) + + +@with_transaction +def send_password_reset_mail(user: User) -> bool: + user.password_reset_token = generate_random_str(64) + + if not password_reset_url: + raise ValueError("No url specified in PASSWORD_RESET_URL") + + try: + user_send_mail(user, UserResetPasswordTemplate( + password_reset_url=password_reset_url.format(user.password_reset_token)), + use_internal=False) + except ValueError: + user.password_reset_token = None + return False + + return True + + +@with_transaction +def change_password_from_token(token: str | None, password: str) -> bool: + if token is None: + return False + + user = User.q.filter_by(password_reset_token=token).one_or_none() + + if user: + change_password(user, password) + user.password_reset_token = None + user.email_confirmed = True + + return True + else: + return False + + +def find_similar_users(name: str, room: Room, ratio: float) -> Iterable[User]: + """Given a potential user's name and a room, find users of similar name living in that room. + + :param name: The potential user's name + :param room: the room whose inhabitants to search + :param ratio: the threshold which determines which matches are included in this list. + For that, the `difflib.SequenceMatcher.ratio` must be greater than the given value. + """ + relevant_users_q = (session.session.query(User) + .join(RoomHistoryEntry) + .filter(RoomHistoryEntry.room == room)) + return [u for u in relevant_users_q if are_names_similar(name, u.name, threshold=ratio)] + + +def are_names_similar(one: str, other: str, threshold: float) -> bool: + return SequenceMatcher(a=one, b=other).ratio() > threshold diff --git a/pycroft/lib/user/member_request.py b/pycroft/lib/user/member_request.py new file mode 100644 index 000000000..e69de29bb diff --git a/web/blueprints/user/__init__.py b/web/blueprints/user/__init__.py index 60f78e31e..e55725125 100644 --- a/web/blueprints/user/__init__.py +++ b/web/blueprints/user/__init__.py @@ -655,7 +655,7 @@ def default_response() -> ResponseReturnValue: if not form.validate_on_submit(): return default_response() with abort_on_error(default_response), session.session.begin_nested(): - address = lib.user.get_or_create_address(**form.address_kwargs) + address = lib.address.get_or_create_address(**form.address_kwargs) new_user, plain_password = lib.user.create_user( name=form.name.data, login=form.login.data, From ab30dbfaa0fcf64a5fecc843afc36042f418eff0 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Wed, 4 Sep 2024 15:49:52 +0200 Subject: [PATCH 04/32] Extract `lib.user.member_request`, `.exc`, and dependees --- pycroft/lib/user/__init__.py | 39 +-- pycroft/lib/user/_old.py | 348 +------------------------- pycroft/lib/user/exc.py | 38 +++ pycroft/lib/user/mail_confirmation.py | 61 +++++ pycroft/lib/user/member_request.py | 336 +++++++++++++++++++++++++ 5 files changed, 471 insertions(+), 351 deletions(-) create mode 100644 pycroft/lib/user/exc.py create mode 100644 pycroft/lib/user/mail_confirmation.py diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index 892707e78..ab778a850 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -4,7 +4,6 @@ encode_type2_user_id, decode_type2_user_id, check_user_id, - HostAliasExists, setup_ipv4_networking, store_user_sheet, get_user_sheet, @@ -45,30 +44,38 @@ group_send_mail, send_member_request_merged_email, send_confirmation_email, - LoginTakenException, - EmailTakenException, - UserExistsInRoomException, - UserExistsException, - NoTenancyForRoomException, - MoveInDateInvalidException, get_similar_users_in_room, check_similar_user_in_room, get_user_by_swdd_person_id, - check_new_user_data, - check_new_user_data_unused, + get_name_from_first_last, + get_user_by_id_or_login, + send_password_reset_mail, + change_password_from_token, + find_similar_users, + are_names_similar, +) +from .member_request import ( create_member_request, finish_member_request, user_from_pre_member, - confirm_mail_address, get_member_requests, - get_name_from_first_last, delete_member_request, merge_member_request, get_possible_existing_users_for_pre_member, - get_user_by_id_or_login, - send_password_reset_mail, - change_password_from_token, - find_similar_users, - are_names_similar, + check_new_user_data, + check_new_user_data_unused, +) + +from .mail_confirmation import ( + confirm_mail_address, ) +from .exc import ( + HostAliasExists, + LoginTakenException, + EmailTakenException, + UserExistsInRoomException, + UserExistsException, + NoTenancyForRoomException, + MoveInDateInvalidException, +) diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index e8c6cafdc..805c6b320 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -21,7 +21,7 @@ from sqlalchemy.orm import Session from pycroft import config, property -from pycroft.helpers import user as user_helper, utc +from pycroft.helpers import user as user_helper from pycroft.helpers.errorcode import Type1Code, Type2Code from pycroft.helpers.i18n import deferred_gettext from pycroft.helpers.interval import closed, Interval, starting_from @@ -29,19 +29,21 @@ from pycroft.helpers.user import generate_random_str, login_hash from pycroft.helpers.utc import DateTimeTz from pycroft.lib.address import get_or_create_address -from pycroft.lib.exc import PycroftLibException from pycroft.lib.facilities import get_room from pycroft.lib.finance import user_has_paid -from pycroft.lib.logging import log_user_event, log_event -from pycroft.lib.mail import MailTemplate, Mail, UserConfirmEmailTemplate, \ - UserCreatedTemplate, \ - UserMovedInTemplate, MemberRequestPendingTemplate, \ - MemberRequestDeniedTemplate, \ - MemberRequestMergedTemplate, UserResetPasswordTemplate +from pycroft.lib.logging import log_user_event +from pycroft.lib.mail import ( + MailTemplate, + Mail, + UserConfirmEmailTemplate, + UserCreatedTemplate, + UserMovedInTemplate, + MemberRequestMergedTemplate, + UserResetPasswordTemplate, +) from pycroft.lib.membership import make_member_of, remove_member_of from pycroft.lib.net import get_free_ip, MacExistsException, \ get_subnets_for_room -from pycroft.lib.swdd import get_relevant_tenancies from pycroft.lib.task import schedule_user_task from pycroft.model import session from pycroft.model.address import Address @@ -66,6 +68,8 @@ from pycroft.model.webstorage import WebStorage from pycroft.task import send_mails_async +from .exc import LoginTakenException, UserExistsInRoomException + mail_confirm_url = os.getenv('MAIL_CONFIRM_URL') password_reset_url = os.getenv('PASSWORD_RESET_URL') @@ -129,10 +133,6 @@ def check_user_id(string: str) -> bool: return string == encode(int(uid)) -class HostAliasExists(ValueError): - pass - - def setup_ipv4_networking(host: Host) -> None: """Add suitable ips for every interface of a host""" subnets = get_subnets_for_room(host.room) @@ -1158,37 +1158,6 @@ def send_confirmation_email(user: BaseUser) -> None: email_confirm_url=mail_confirm_url.format(user.email_confirmation_key))) -class LoginTakenException(PycroftLibException): - def __init__(self, login: str | None = None) -> None: - msg = "Login already taken" if not login else f"Login {login!r} already taken" - super().__init__(msg) - - -class EmailTakenException(PycroftLibException): - def __init__(self) -> None: - super().__init__("E-Mail address already in use") - - -class UserExistsInRoomException(PycroftLibException): - def __init__(self) -> None: - super().__init__("A user with a similar name already lives in this room") - - -class UserExistsException(PycroftLibException): - def __init__(self) -> None: - super().__init__("This user already exists") - - -class NoTenancyForRoomException(PycroftLibException): - def __init__(self) -> None: - super().__init__("This user has no tenancy in that room") - - -class MoveInDateInvalidException(PycroftLibException): - def __init__(self) -> None: - super().__init__("The move-in date is invalid (in the past or more than 6 months in the future)") - - def get_similar_users_in_room(name: str, room: Room, ratio: float = 0.75) -> list[User]: """Get inhabitants of a room with a name similar to the given name. @@ -1221,301 +1190,10 @@ def get_user_by_swdd_person_id(swdd_person_id: int | None) -> User | None: ) -def check_new_user_data( - login: str, - email: str, - name: str, - swdd_person_id: int | None, - room: Room | None, - move_in_date: date | None, - ignore_similar_name: bool = False, -) -> None: - if room is not None and not ignore_similar_name: - check_similar_user_in_room(name, room) - - if move_in_date is not None: - utcnow = session.utcnow() - if not utcnow.date() <= move_in_date <= (utcnow + timedelta(days=180)).date(): - raise MoveInDateInvalidException - - -def check_new_user_data_unused(login: str, email: str, swdd_person_id: int) -> None: - """Check whether some user data from a member request is already used. - - :raises UserExistsException: - :raises LoginTakenException: - :raises EmailTakenException: - """ - user_swdd_person_id = get_user_by_swdd_person_id(swdd_person_id) - if user_swdd_person_id: - raise UserExistsException - - if not login_available(login, session=session.session): - raise LoginTakenException - - user_email = User.q.filter_by(email=email).first() - if user_email is not None: - raise EmailTakenException - - -@with_transaction -def create_member_request( - name: str, - email: str, - password: str, - login: str, - birthdate: date, - swdd_person_id: int | None, - room: Room | None, - move_in_date: date | None, - previous_dorm: str | None, -) -> PreMember: - check_new_user_data( - login, - email, - name, - swdd_person_id, - room, - move_in_date, - ) - if previous_dorm is None: - check_new_user_data_unused(login=login, mail=email, swdd_person_id=swdd_person_id) - - if swdd_person_id is not None and room is not None: - tenancies = get_relevant_tenancies(swdd_person_id) - - rooms = [tenancy.room for tenancy in tenancies] - - if room not in rooms: - raise NoTenancyForRoomException - - mr = PreMember(name=name, email=email, swdd_person_id=swdd_person_id, - password=password, room=room, login=login, move_in_date=move_in_date, - birthdate=birthdate, registered_at=session.utcnow(), - previous_dorm=previous_dorm) - - session.session.add(mr) - session.session.flush() - - # Send confirmation mail - send_confirmation_email(mr) - - return mr - - -@with_transaction -def finish_member_request( - prm: PreMember, processor: User | None, ignore_similar_name: bool = False -) -> User: - if prm.room is None: - raise ValueError("Room is None") - - utcnow = session.utcnow() - - if prm.move_in_date is not None and prm.move_in_date < utcnow.date(): - prm.move_in_date = utcnow.date() - - check_new_user_data(prm.login, prm.email, prm.name, prm.swdd_person_id, prm.room, - prm.move_in_date, ignore_similar_name) - - user = user_from_pre_member(prm, processor=processor) - processor = processor or user - assert processor is not None - - move_in_datetime = utc.with_min_time(prm.move_in_date) - move_in( - user, - prm.room.building_id, - prm.room.level, - prm.room.number, - None, - processor, - when=move_in_datetime, - ) - - if move_in_datetime > utcnow: - make_member_of(user, config.pre_member_group, processor, closed(utcnow, None)) - - session.session.delete(prm) - - return user - - -def user_from_pre_member(pre_member: PreMember, processor: User) -> User: - user, _ = create_user( - pre_member.name, - pre_member.login, - pre_member.email, - pre_member.birthdate, - groups=[], - processor=processor, - address=pre_member.room.address, - passwd_hash=pre_member.passwd_hash, - ) - - processor = processor if processor is not None else user - - user.swdd_person_id = pre_member.swdd_person_id - user.email_confirmed = pre_member.email_confirmed - - message = deferred_gettext("Created from registration {}.").format(str(pre_member.id)).to_json() - log_user_event(message, processor, user) - - - -@with_transaction -def confirm_mail_address( - key: str, -) -> tuple[ - t.Literal["pre_member", "user"], - t.Literal["account_created", "request_pending"] | None, -]: - if not key: - raise ValueError("No key given") - - mr = PreMember.q.filter_by(email_confirmation_key=key).one_or_none() - user = User.q.filter_by(email_confirmation_key=key).one_or_none() - - if mr is None and user is None: - raise ValueError("Unknown confirmation key") - # else: one of {mr, user} is not None - - if user is None: - if mr.email_confirmed: - raise ValueError("E-Mail already confirmed") - - mr.email_confirmed = True - mr.email_confirmation_key = None - - reg_result: t.Literal["account_created", "request_pending"] - if mr.swdd_person_id is not None and mr.room is not None and mr.previous_dorm is None \ - and mr.is_adult: - finish_member_request(mr, None) - reg_result = 'account_created' - else: - user_send_mail(mr, MemberRequestPendingTemplate(is_adult=mr.is_adult)) - reg_result = 'request_pending' - - return 'pre_member', reg_result - elif mr is None: - user.email_confirmed = True - user.email_confirmation_key = None - - return 'user', None - else: - raise RuntimeError( - "Same mail confirmation key has been given to both a PreMember and a User" - ) - - -def get_member_requests() -> list[PreMember]: - prms = PreMember.q.order_by(PreMember.email_confirmed.desc())\ - .order_by(PreMember.registered_at.asc()).all() - - return prms - - def get_name_from_first_last(first_name: str, last_name: str) -> str: return f"{first_name} {last_name}" if last_name else first_name -@with_transaction -def delete_member_request( - prm: PreMember, reason: str | None, processor: User, inform_user: bool = True -) -> None: - - if reason is None: - reason = "Keine Begründung angegeben." - - log_event(deferred_gettext("Deleted member request {}. Reason: {}").format(prm.id, reason).to_json(), - processor) - - if inform_user: - user_send_mail(prm, MemberRequestDeniedTemplate(reason=reason), soft_fail=True) - - session.session.delete(prm) - - -@with_transaction -def merge_member_request( - user: User, - prm: PreMember, - merge_name: bool, - merge_email: bool, - merge_person_id: bool, - merge_room: bool, - merge_password: bool, - merge_birthdate: bool, - processor: User, -) -> None: - if prm.move_in_date is not None and prm.move_in_date < session.utcnow().date(): - prm.move_in_date = session.utcnow().date() - - if merge_name: - user = edit_name(user, prm.name, processor) - - if merge_email: - user = edit_email(user, prm.email, user.email_forwarded, processor, - is_confirmed=prm.email_confirmed) - - if merge_person_id: - user = edit_person_id(user, prm.swdd_person_id, processor) - - move_in_datetime = utc.with_min_time(prm.move_in_date) - - if merge_room: - if prm.room: - if user.room: - move(user, prm.room.building_id, prm.room.level, prm.room.number, - processor=processor, when=move_in_datetime) - - if not user.member_of(config.member_group): - make_member_of(user, config.member_group, processor, - closed(move_in_datetime, None)) - - if move_in_datetime > session.utcnow(): - make_member_of(user, config.pre_member_group, processor, - closed(session.utcnow(), move_in_datetime)) - else: - move_in(user, prm.room.building_id, prm.room.level, prm.room.number, - mac=None, processor=processor, when=move_in_datetime) - - if move_in_datetime > session.utcnow(): - make_member_of(user, config.pre_member_group, processor, - closed(session.utcnow(), None)) - - if merge_birthdate: - user = edit_birthdate(user, prm.birthdate, processor) - - log_msg = "Merged information from registration {}." - - if merge_password: - user.passwd_hash = prm.passwd_hash - - log_msg += " Password overridden." - else: - log_msg += " Kept old password." - - log_user_event(deferred_gettext(log_msg).format(encode_type2_user_id(prm.id)).to_json(), - processor, user) - - session.session.delete(prm) - - -def get_possible_existing_users_for_pre_member(prm: PreMember) -> set[User]: - user_swdd_person_id = get_user_by_swdd_person_id(prm.swdd_person_id) - user_login = User.q.filter_by(login=prm.login).first() - user_email = User.q.filter(func.lower(User.email) == prm.email.lower()).first() - - users_name = User.q.filter_by(name=prm.name).all() - users_similar = get_similar_users_in_room(prm.name, prm.room, 0.5) - - users = {user for user in [user_swdd_person_id, user_login, user_email] - + users_name + users_similar if user is not None} - - return users - - def get_user_by_id_or_login(ident: str, email: str) -> User | None: re_uid1 = r"^\d{4,6}-\d{1}$" re_uid2 = r"^\d{4,6}-\d{2}$" diff --git a/pycroft/lib/user/exc.py b/pycroft/lib/user/exc.py new file mode 100644 index 000000000..e15a194c7 --- /dev/null +++ b/pycroft/lib/user/exc.py @@ -0,0 +1,38 @@ +from ..exc import PycroftLibException + + +class HostAliasExists(ValueError): + pass + + +class LoginTakenException(PycroftLibException): + def __init__(self, login: str | None = None) -> None: + msg = "Login already taken" if not login else f"Login {login!r} already taken" + super().__init__(msg) + + +class EmailTakenException(PycroftLibException): + def __init__(self) -> None: + super().__init__("E-Mail address already in use") + + +class UserExistsInRoomException(PycroftLibException): + def __init__(self) -> None: + super().__init__("A user with a similar name already lives in this room") + + +class UserExistsException(PycroftLibException): + def __init__(self) -> None: + super().__init__("This user already exists") + + +class NoTenancyForRoomException(PycroftLibException): + def __init__(self) -> None: + super().__init__("This user has no tenancy in that room") + + +class MoveInDateInvalidException(PycroftLibException): + def __init__(self) -> None: + super().__init__( + "The move-in date is invalid (in the past or more than 6 months in the future)" + ) diff --git a/pycroft/lib/user/mail_confirmation.py b/pycroft/lib/user/mail_confirmation.py new file mode 100644 index 000000000..d5a6017e4 --- /dev/null +++ b/pycroft/lib/user/mail_confirmation.py @@ -0,0 +1,61 @@ +import typing as t + + +from pycroft.lib.mail import MemberRequestPendingTemplate +from pycroft.model.session import with_transaction +from pycroft.model.user import ( + User, + PreMember, +) + +from .member_request import finish_member_request +from ._old import user_send_mail + + +@with_transaction +def confirm_mail_address( + key: str, +) -> tuple[ + t.Literal["pre_member", "user"], + t.Literal["account_created", "request_pending"] | None, +]: + if not key: + raise ValueError("No key given") + + mr = PreMember.q.filter_by(email_confirmation_key=key).one_or_none() + user = User.q.filter_by(email_confirmation_key=key).one_or_none() + + if mr is None and user is None: + raise ValueError("Unknown confirmation key") + # else: one of {mr, user} is not None + + if user is None: + if mr.email_confirmed: + raise ValueError("E-Mail already confirmed") + + mr.email_confirmed = True + mr.email_confirmation_key = None + + reg_result: t.Literal["account_created", "request_pending"] + if ( + mr.swdd_person_id is not None + and mr.room is not None + and mr.previous_dorm is None + and mr.is_adult + ): + finish_member_request(mr, None) + reg_result = "account_created" + else: + user_send_mail(mr, MemberRequestPendingTemplate(is_adult=mr.is_adult)) + reg_result = "request_pending" + + return "pre_member", reg_result + elif mr is None: + user.email_confirmed = True + user.email_confirmation_key = None + + return "user", None + else: + raise RuntimeError( + "Same mail confirmation key has been given to both a PreMember and a User" + ) diff --git a/pycroft/lib/user/member_request.py b/pycroft/lib/user/member_request.py index e69de29bb..375a4c4c1 100644 --- a/pycroft/lib/user/member_request.py +++ b/pycroft/lib/user/member_request.py @@ -0,0 +1,336 @@ +from datetime import timedelta, date + +from sqlalchemy import func + +from pycroft import config +from pycroft.helpers import utc +from pycroft.helpers.i18n import deferred_gettext +from pycroft.helpers.interval import closed +from pycroft.lib.logging import log_user_event, log_event +from pycroft.lib.mail import MemberRequestDeniedTemplate +from pycroft.lib.membership import make_member_of +from pycroft.lib.swdd import get_relevant_tenancies +from pycroft.model import session +from pycroft.model.facilities import Room +from pycroft.model.session import with_transaction +from pycroft.model.user import ( + User, + PreMember, +) + +from ._old import ( + create_user, + move_in, + move, + edit_birthdate, + edit_name, + edit_email, + edit_person_id, + encode_type2_user_id, + get_user_by_swdd_person_id, + get_similar_users_in_room, + check_similar_user_in_room, + login_available, + send_confirmation_email, + user_send_mail, +) +from .exc import ( + LoginTakenException, + EmailTakenException, + UserExistsException, + NoTenancyForRoomException, + MoveInDateInvalidException, +) + + +@with_transaction +def create_member_request( + name: str, + email: str, + password: str, + login: str, + birthdate: date, + swdd_person_id: int | None, + room: Room | None, + move_in_date: date | None, + previous_dorm: str | None, +) -> PreMember: + check_new_user_data( + login, + email, + name, + swdd_person_id, + room, + move_in_date, + ) + if previous_dorm is None: + check_new_user_data_unused(login=login, email=email, swdd_person_id=swdd_person_id) + + if swdd_person_id is not None and room is not None: + tenancies = get_relevant_tenancies(swdd_person_id) + + rooms = [tenancy.room for tenancy in tenancies] + + if room not in rooms: + raise NoTenancyForRoomException + + mr = PreMember( + name=name, + email=email, + swdd_person_id=swdd_person_id, + password=password, + room=room, + login=login, + move_in_date=move_in_date, + birthdate=birthdate, + registered_at=session.utcnow(), + previous_dorm=previous_dorm, + ) + + session.session.add(mr) + session.session.flush() + + # Send confirmation mail + send_confirmation_email(mr) + + return mr + + +@with_transaction +def finish_member_request( + prm: PreMember, processor: User | None, ignore_similar_name: bool = False +) -> User: + if prm.room is None: + raise ValueError("Room is None") + + utcnow = session.utcnow() + + if prm.move_in_date is not None and prm.move_in_date < utcnow.date(): + prm.move_in_date = utcnow.date() + + check_new_user_data( + prm.login, + prm.email, + prm.name, + prm.swdd_person_id, + prm.room, + prm.move_in_date, + ignore_similar_name, + ) + + user = user_from_pre_member(prm, processor=processor) + processor = processor or user + assert processor is not None + + move_in_datetime = utc.with_min_time(prm.move_in_date) + move_in( + user, + prm.room.building_id, + prm.room.level, + prm.room.number, + None, + processor, + when=move_in_datetime, + ) + + if move_in_datetime > utcnow: + make_member_of(user, config.pre_member_group, processor, closed(utcnow, None)) + + session.session.delete(prm) + + return user + + +def user_from_pre_member(pre_member: PreMember, processor: User) -> User: + user, _ = create_user( + pre_member.name, + pre_member.login, + pre_member.email, + pre_member.birthdate, + groups=[], + processor=processor, + address=pre_member.room.address, + passwd_hash=pre_member.passwd_hash, + ) + + processor = processor if processor is not None else user + + user.swdd_person_id = pre_member.swdd_person_id + user.email_confirmed = pre_member.email_confirmed + + message = deferred_gettext("Created from registration {}.").format(str(pre_member.id)).to_json() + log_user_event(message, processor, user) + + return user + + +def get_member_requests() -> list[PreMember]: + prms = ( + PreMember.q.order_by(PreMember.email_confirmed.desc()) + .order_by(PreMember.registered_at.asc()) + .all() + ) + + return prms + + +@with_transaction +def delete_member_request( + prm: PreMember, reason: str | None, processor: User, inform_user: bool = True +) -> None: + + if reason is None: + reason = "Keine Begründung angegeben." + + log_event( + deferred_gettext("Deleted member request {}. Reason: {}").format(prm.id, reason).to_json(), + processor, + ) + + if inform_user: + user_send_mail(prm, MemberRequestDeniedTemplate(reason=reason), soft_fail=True) + + session.session.delete(prm) + + +@with_transaction +def merge_member_request( + user: User, + prm: PreMember, + merge_name: bool, + merge_email: bool, + merge_person_id: bool, + merge_room: bool, + merge_password: bool, + merge_birthdate: bool, + processor: User, +) -> None: + if prm.move_in_date is not None and prm.move_in_date < session.utcnow().date(): + prm.move_in_date = session.utcnow().date() + + if merge_name: + user = edit_name(user, prm.name, processor) + + if merge_email: + user = edit_email( + user, prm.email, user.email_forwarded, processor, is_confirmed=prm.email_confirmed + ) + + if merge_person_id: + user = edit_person_id(user, prm.swdd_person_id, processor) + + move_in_datetime = utc.with_min_time(prm.move_in_date) + + if merge_room: + if prm.room: + if user.room: + move( + user, + prm.room.building_id, + prm.room.level, + prm.room.number, + processor=processor, + when=move_in_datetime, + ) + + if not user.member_of(config.member_group): + make_member_of( + user, config.member_group, processor, closed(move_in_datetime, None) + ) + + if move_in_datetime > session.utcnow(): + make_member_of( + user, + config.pre_member_group, + processor, + closed(session.utcnow(), move_in_datetime), + ) + else: + move_in( + user, + prm.room.building_id, + prm.room.level, + prm.room.number, + mac=None, + processor=processor, + when=move_in_datetime, + ) + + if move_in_datetime > session.utcnow(): + make_member_of( + user, config.pre_member_group, processor, closed(session.utcnow(), None) + ) + + if merge_birthdate: + user = edit_birthdate(user, prm.birthdate, processor) + + log_msg = "Merged information from registration {}." + + if merge_password: + user.passwd_hash = prm.passwd_hash + + log_msg += " Password overridden." + else: + log_msg += " Kept old password." + + log_user_event( + deferred_gettext(log_msg).format(encode_type2_user_id(prm.id)).to_json(), processor, user + ) + + session.session.delete(prm) + + +def get_possible_existing_users_for_pre_member(prm: PreMember) -> set[User]: + user_swdd_person_id = get_user_by_swdd_person_id(prm.swdd_person_id) + user_login = User.q.filter_by(login=prm.login).first() + user_email = User.q.filter(func.lower(User.email) == prm.email.lower()).first() + + users_name = User.q.filter_by(name=prm.name).all() + users_similar = get_similar_users_in_room(prm.name, prm.room, 0.5) + + users = { + user + for user in [user_swdd_person_id, user_login, user_email] + users_name + users_similar + if user is not None + } + + return users + + +def check_new_user_data( + login: str, + email: str, + name: str, + swdd_person_id: int | None, + room: Room | None, + move_in_date: date | None, + ignore_similar_name: bool = False, +) -> None: + if room is not None and not ignore_similar_name: + check_similar_user_in_room(name, room) + + if move_in_date is not None: + utcnow = session.utcnow() + if not utcnow.date() <= move_in_date <= (utcnow + timedelta(days=180)).date(): + raise MoveInDateInvalidException + + +def check_new_user_data_unused(login: str, email: str, swdd_person_id: int) -> None: + """Check whether some user data from a member request is already used. + + :raises UserExistsException: + :raises LoginTakenException: + :raises EmailTakenException: + """ + user_swdd_person_id = get_user_by_swdd_person_id(swdd_person_id) + if user_swdd_person_id: + raise UserExistsException + + if not login_available(login, session=session.session): + raise LoginTakenException + + user_email = User.q.filter_by(email=email).first() + if user_email is not None: + raise EmailTakenException + + return From a0b172b7f61cb3d8e93fc7204db54a0e91e2056b Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Wed, 4 Sep 2024 15:54:41 +0200 Subject: [PATCH 05/32] Extract `pycroft.lib.user_id` --- pycroft/lib/user/__init__.py | 12 +++--- pycroft/lib/user/_old.py | 66 +++--------------------------- pycroft/lib/user/member_request.py | 2 +- pycroft/lib/user/user_id.py | 64 +++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 66 deletions(-) create mode 100644 pycroft/lib/user/user_id.py diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index ab778a850..5a3d37c39 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -1,9 +1,4 @@ from ._old import ( - encode_type1_user_id, - decode_type1_user_id, - encode_type2_user_id, - decode_type2_user_id, - check_user_id, setup_ipv4_networking, store_user_sheet, get_user_sheet, @@ -54,6 +49,13 @@ find_similar_users, are_names_similar, ) +from .user_id import ( + encode_type1_user_id, + decode_type1_user_id, + encode_type2_user_id, + decode_type2_user_id, + check_user_id, +) from .member_request import ( create_member_request, finish_member_request, diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index 805c6b320..2962748ca 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -22,7 +22,6 @@ from pycroft import config, property from pycroft.helpers import user as user_helper -from pycroft.helpers.errorcode import Type1Code, Type2Code from pycroft.helpers.i18n import deferred_gettext from pycroft.helpers.interval import closed, Interval, starting_from from pycroft.helpers.printing import generate_user_sheet as generate_pdf @@ -69,70 +68,17 @@ from pycroft.task import send_mails_async from .exc import LoginTakenException, UserExistsInRoomException +from .user_id import ( + decode_type1_user_id, + encode_type2_user_id, + decode_type2_user_id, + check_user_id, +) mail_confirm_url = os.getenv('MAIL_CONFIRM_URL') password_reset_url = os.getenv('PASSWORD_RESET_URL') -def encode_type1_user_id(user_id: int) -> str: - """Append a type-1 error detection code to the user_id.""" - return f"{user_id:04d}-{Type1Code.calculate(user_id):d}" - - -type1_user_id_pattern = re.compile(r"^(\d{4,})-(\d)$") - - -def decode_type1_user_id(string: str) -> tuple[str, str] | None: - """ - If a given string is a type1 user id return a (user_id, code) tuple else - return None. - - :param ustring: Type1 encoded user ID - :returns: (number, code) pair or None - """ - match = type1_user_id_pattern.match(string) - return t.cast(tuple[str, str], match.groups()) if match else None - - -def encode_type2_user_id(user_id: int) -> str: - """Append a type-2 error detection code to the user_id.""" - return f"{user_id:04d}-{Type2Code.calculate(user_id):02d}" - - -type2_user_id_pattern = re.compile(r"^(\d{4,})-(\d{2})$") - - -def decode_type2_user_id(string: str) -> tuple[str, str] | None: - """ - If a given string is a type2 user id return a (user_id, code) tuple else - return None. - - :param unicode string: Type2 encoded user ID - :returns: (number, code) pair or None - :rtype: (Integral, Integral) | None - """ - match = type2_user_id_pattern.match(string) - return t.cast(tuple[str, str], match.groups()) if match else None - - -def check_user_id(string: str) -> bool: - """ - Check if the given string is a valid user id (type1 or type2). - - :param string: Type1 or Type2 encoded user ID - :returns: True if user id was valid, otherwise False - :rtype: Boolean - """ - if not string: - return False - idsplit = string.split("-") - if len(idsplit) != 2: - return False - uid, code = idsplit - encode = encode_type2_user_id if len(code) == 2 else encode_type1_user_id - return string == encode(int(uid)) - - def setup_ipv4_networking(host: Host) -> None: """Add suitable ips for every interface of a host""" subnets = get_subnets_for_room(host.room) diff --git a/pycroft/lib/user/member_request.py b/pycroft/lib/user/member_request.py index 375a4c4c1..0fc7f3414 100644 --- a/pycroft/lib/user/member_request.py +++ b/pycroft/lib/user/member_request.py @@ -26,7 +26,6 @@ edit_name, edit_email, edit_person_id, - encode_type2_user_id, get_user_by_swdd_person_id, get_similar_users_in_room, check_similar_user_in_room, @@ -41,6 +40,7 @@ NoTenancyForRoomException, MoveInDateInvalidException, ) +from .user_id import encode_type2_user_id @with_transaction diff --git a/pycroft/lib/user/user_id.py b/pycroft/lib/user/user_id.py new file mode 100644 index 000000000..b541c3d9b --- /dev/null +++ b/pycroft/lib/user/user_id.py @@ -0,0 +1,64 @@ +import re +import typing as t + + +from pycroft.helpers.errorcode import Type1Code, Type2Code + + +def encode_type1_user_id(user_id: int) -> str: + """Append a type-1 error detection code to the user_id.""" + return f"{user_id:04d}-{Type1Code.calculate(user_id):d}" + + +type1_user_id_pattern = re.compile(r"^(\d{4,})-(\d)$") + + +def decode_type1_user_id(string: str) -> tuple[str, str] | None: + """ + If a given string is a type1 user id return a (user_id, code) tuple else + return None. + + :param ustring: Type1 encoded user ID + :returns: (number, code) pair or None + """ + match = type1_user_id_pattern.match(string) + return t.cast(tuple[str, str], match.groups()) if match else None + + +def encode_type2_user_id(user_id: int) -> str: + """Append a type-2 error detection code to the user_id.""" + return f"{user_id:04d}-{Type2Code.calculate(user_id):02d}" + + +type2_user_id_pattern = re.compile(r"^(\d{4,})-(\d{2})$") + + +def decode_type2_user_id(string: str) -> tuple[str, str] | None: + """ + If a given string is a type2 user id return a (user_id, code) tuple else + return None. + + :param unicode string: Type2 encoded user ID + :returns: (number, code) pair or None + :rtype: (Integral, Integral) | None + """ + match = type2_user_id_pattern.match(string) + return t.cast(tuple[str, str], match.groups()) if match else None + + +def check_user_id(string: str) -> bool: + """ + Check if the given string is a valid user id (type1 or type2). + + :param string: Type1 or Type2 encoded user ID + :returns: True if user id was valid, otherwise False + :rtype: Boolean + """ + if not string: + return False + idsplit = string.split("-") + if len(idsplit) != 2: + return False + uid, code = idsplit + encode = encode_type2_user_id if len(code) == 2 else encode_type1_user_id + return string == encode(int(uid)) From b735b84673f96d040ae08c0edd38df386f989af3 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Wed, 4 Sep 2024 15:59:21 +0200 Subject: [PATCH 06/32] extract `lib.user.edit` --- pycroft/lib/user/__init__.py | 12 +- pycroft/lib/user/_old.py | 155 -------------------------- pycroft/lib/user/edit.py | 172 +++++++++++++++++++++++++++++ pycroft/lib/user/member_request.py | 10 +- 4 files changed, 185 insertions(+), 164 deletions(-) create mode 100644 pycroft/lib/user/edit.py diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index 5a3d37c39..084c5fe18 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -13,11 +13,6 @@ move_in, migrate_user_host, move, - edit_name, - edit_email, - edit_birthdate, - edit_person_id, - edit_address, traffic_history, has_balance_of_at_least, has_positive_balance, @@ -56,6 +51,13 @@ decode_type2_user_id, check_user_id, ) +from .edit import ( + edit_name, + edit_email, + edit_birthdate, + edit_person_id, + edit_address, +) from .member_request import ( create_member_request, finish_member_request, diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index 2962748ca..a4b862a78 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -27,7 +27,6 @@ from pycroft.helpers.printing import generate_user_sheet as generate_pdf from pycroft.helpers.user import generate_random_str, login_hash from pycroft.helpers.utc import DateTimeTz -from pycroft.lib.address import get_or_create_address from pycroft.lib.facilities import get_room from pycroft.lib.finance import user_has_paid from pycroft.lib.logging import log_user_event @@ -504,160 +503,6 @@ def move( return user -@with_transaction -def edit_name(user: User, name: str, processor: User) -> User: - """Changes the name of the user and creates a log entry. - - :param user: The user object. - :param name: The new full name. - :return: The changed user object. - """ - - if not name: - raise ValueError() - - if name == user.name: - # name wasn't changed, do nothing - return user - - old_name = user.name - user.name = name - message = deferred_gettext("Changed name from {} to {}.") - log_user_event(author=processor, user=user, - message=message.format(old_name, name).to_json()) - return user - - -@with_transaction -def edit_email( - user: User, - email: str | None, - email_forwarded: bool, - processor: User, - is_confirmed: bool = False, -) -> User: - """ - Changes the email address of a user and creates a log entry. - - :param user: User object to change - :param email: New email address (empty interpreted as ``None``) - :param email_forwarded: Boolean if emails should be forwarded - :param processor: User object of the processor, which issues the change - :param is_confirmed: If the email address is already confirmed - :return: Changed user object - """ - - if not can_target(user, processor): - raise PermissionError("cannot change email of a user with a" - " greater or equal permission level.") - - if not email: - email = None - else: - email = email.lower() - - if email_forwarded != user.email_forwarded: - user.email_forwarded = email_forwarded - - log_user_event(author=processor, user=user, - message=deferred_gettext("Set e-mail forwarding to {}.") - .format(email_forwarded).to_json()) - - if is_confirmed: - user.email_confirmed = True - user.email_confirmation_key = None - - if email == user.email: - # email wasn't changed, do nothing - return user - - old_email = user.email - user.email = email - - if email is not None: - if not is_confirmed: - send_confirmation_email(user) - else: - user.email_confirmed = False - user.email_confirmation_key = None - - message = deferred_gettext("Changed e-mail from {} to {}.") - log_user_event(author=processor, user=user, - message=message.format(old_email, email).to_json()) - return user - - -@with_transaction -def edit_birthdate(user: User, birthdate: date, processor: User) -> User: - """ - Changes the birthdate of a user and creates a log entry. - - :param user: User object to change - :param birthdate: New birthdate - :param processor: User object of the processor, which issues the change - :return: Changed user object - """ - - if not birthdate: - birthdate = None - - if birthdate == user.birthdate: - # birthdate wasn't changed, do nothing - return user - - old_bd = user.birthdate - user.birthdate = birthdate - message = deferred_gettext("Changed birthdate from {} to {}.") - log_user_event(author=processor, user=user, - message=message.format(old_bd, birthdate).to_json()) - return user - - -@with_transaction -def edit_person_id(user: User, person_id: int, processor: User) -> User: - """ - Changes the swdd_person_id of the user and creates a log entry. - - :param user: The user object. - :param person_id: The new person_id. - :return: The changed user object. - """ - - if person_id == user.swdd_person_id: - # name wasn't changed, do nothing - return user - - old_person_id = user.swdd_person_id - user.swdd_person_id = person_id - message = deferred_gettext("Changed tenant number from {} to {}.") - log_user_event(author=processor, user=user, - message=message.format(str(old_person_id), str(person_id)).to_json()) - - return user - - -@with_transaction -def edit_address( - user: User, - processor: User, - street: str, - number: str, - addition: str | None, - zip_code: str, - city: str | None, - state: str | None, - country: str | None, -) -> None: - """Changes the address of a user and appends a log entry. - - Should do nothing if the user already has an address. - """ - address = get_or_create_address(street, number, addition, zip_code, city, state, country) - user.address = address - log_user_event(deferred_gettext("Changed address to {address}").format(address=str(address)).to_json(), - processor, user) - - def traffic_history( user_id: int, start: DateTimeTz | ColumnElement[DateTimeTz], diff --git a/pycroft/lib/user/edit.py b/pycroft/lib/user/edit.py new file mode 100644 index 000000000..f731d1e31 --- /dev/null +++ b/pycroft/lib/user/edit.py @@ -0,0 +1,172 @@ +from datetime import date + + +from pycroft.helpers.i18n import deferred_gettext +from pycroft.lib.address import get_or_create_address +from pycroft.lib.logging import log_user_event +from pycroft.model.session import with_transaction +from pycroft.model.user import User + +from ._old import can_target, send_confirmation_email + + +@with_transaction +def edit_name(user: User, name: str, processor: User) -> User: + """Changes the name of the user and creates a log entry. + + :param user: The user object. + :param name: The new full name. + :return: The changed user object. + """ + + if not name: + raise ValueError() + + if name == user.name: + # name wasn't changed, do nothing + return user + + old_name = user.name + user.name = name + message = deferred_gettext("Changed name from {} to {}.") + log_user_event(author=processor, user=user, message=message.format(old_name, name).to_json()) + return user + + +@with_transaction +def edit_email( + user: User, + email: str | None, + email_forwarded: bool, + processor: User, + is_confirmed: bool = False, +) -> User: + """ + Changes the email address of a user and creates a log entry. + + :param user: User object to change + :param email: New email address (empty interpreted as ``None``) + :param email_forwarded: Boolean if emails should be forwarded + :param processor: User object of the processor, which issues the change + :param is_confirmed: If the email address is already confirmed + :return: Changed user object + """ + + if not can_target(user, processor): + raise PermissionError( + "cannot change email of a user with a" " greater or equal permission level." + ) + + if not email: + email = None + else: + email = email.lower() + + if email_forwarded != user.email_forwarded: + user.email_forwarded = email_forwarded + + log_user_event( + author=processor, + user=user, + message=deferred_gettext("Set e-mail forwarding to {}.") + .format(email_forwarded) + .to_json(), + ) + + if is_confirmed: + user.email_confirmed = True + user.email_confirmation_key = None + + if email == user.email: + # email wasn't changed, do nothing + return user + + old_email = user.email + user.email = email + + if email is not None: + if not is_confirmed: + send_confirmation_email(user) + else: + user.email_confirmed = False + user.email_confirmation_key = None + + message = deferred_gettext("Changed e-mail from {} to {}.") + log_user_event(author=processor, user=user, message=message.format(old_email, email).to_json()) + return user + + +@with_transaction +def edit_birthdate(user: User, birthdate: date, processor: User) -> User: + """ + Changes the birthdate of a user and creates a log entry. + + :param user: User object to change + :param birthdate: New birthdate + :param processor: User object of the processor, which issues the change + :return: Changed user object + """ + + if not birthdate: + birthdate = None + + if birthdate == user.birthdate: + # birthdate wasn't changed, do nothing + return user + + old_bd = user.birthdate + user.birthdate = birthdate + message = deferred_gettext("Changed birthdate from {} to {}.") + log_user_event(author=processor, user=user, message=message.format(old_bd, birthdate).to_json()) + return user + + +@with_transaction +def edit_person_id(user: User, person_id: int, processor: User) -> User: + """ + Changes the swdd_person_id of the user and creates a log entry. + + :param user: The user object. + :param person_id: The new person_id. + :return: The changed user object. + """ + + if person_id == user.swdd_person_id: + # name wasn't changed, do nothing + return user + + old_person_id = user.swdd_person_id + user.swdd_person_id = person_id + message = deferred_gettext("Changed tenant number from {} to {}.") + log_user_event( + author=processor, + user=user, + message=message.format(str(old_person_id), str(person_id)).to_json(), + ) + + return user + + +@with_transaction +def edit_address( + user: User, + processor: User, + street: str, + number: str, + addition: str | None, + zip_code: str, + city: str | None, + state: str | None, + country: str | None, +) -> None: + """Changes the address of a user and appends a log entry. + + Should do nothing if the user already has an address. + """ + address = get_or_create_address(street, number, addition, zip_code, city, state, country) + user.address = address + log_user_event( + deferred_gettext("Changed address to {address}").format(address=str(address)).to_json(), + processor, + user, + ) diff --git a/pycroft/lib/user/member_request.py b/pycroft/lib/user/member_request.py index 0fc7f3414..f11bf904a 100644 --- a/pycroft/lib/user/member_request.py +++ b/pycroft/lib/user/member_request.py @@ -22,10 +22,6 @@ create_user, move_in, move, - edit_birthdate, - edit_name, - edit_email, - edit_person_id, get_user_by_swdd_person_id, get_similar_users_in_room, check_similar_user_in_room, @@ -33,6 +29,12 @@ send_confirmation_email, user_send_mail, ) +from .edit import ( + edit_birthdate, + edit_name, + edit_email, + edit_person_id, +) from .exc import ( LoginTakenException, EmailTakenException, From 7a7315aa8d004a1e92adfc3380bb10246782123d Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Wed, 4 Sep 2024 16:05:24 +0200 Subject: [PATCH 07/32] extract `lib.user.passwords` --- pycroft/lib/user/__init__.py | 14 ++++--- pycroft/lib/user/_old.py | 69 +-------------------------------- pycroft/lib/user/passwords.py | 72 +++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 74 deletions(-) create mode 100644 pycroft/lib/user/passwords.py diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index 084c5fe18..7f80103da 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -2,12 +2,7 @@ setup_ipv4_networking, store_user_sheet, get_user_sheet, - reset_password, can_target, - reset_wifi_password, - maybe_setup_wifi, - change_password, - generate_wifi_password, create_user, login_available, move_in, @@ -40,7 +35,6 @@ get_name_from_first_last, get_user_by_id_or_login, send_password_reset_mail, - change_password_from_token, find_similar_users, are_names_similar, ) @@ -58,6 +52,14 @@ edit_person_id, edit_address, ) +from .passwords import ( + maybe_setup_wifi, + reset_password, + reset_wifi_password, + change_password, + generate_wifi_password, + change_password_from_token, +) from .member_request import ( create_member_request, finish_member_request, diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index a4b862a78..00b07c22d 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -73,6 +73,7 @@ decode_type2_user_id, check_user_id, ) +from .passwords import generate_wifi_password mail_confirm_url = os.getenv('MAIL_CONFIRM_URL') password_reset_url = os.getenv('PASSWORD_RESET_URL') @@ -140,64 +141,12 @@ def get_user_sheet(sheet_id: int) -> bytes | None: return storage.data -@with_transaction -def reset_password(user: User, processor: User) -> str: - if not can_target(user, processor): - raise PermissionError("cannot reset password of a user with a" - " greater or equal permission level.") - - plain_password = user_helper.generate_password(12) - user.password = plain_password - - message = deferred_gettext("Password was reset") - log_user_event(author=processor, - user=user, - message=message.to_json()) - - return plain_password - def can_target(user: User, processor: User) -> bool: if user != processor: return user.permission_level < processor.permission_level else: return True - -@with_transaction -def reset_wifi_password(user: User, processor: User) -> str: - plain_password = generate_wifi_password() - user.wifi_password = plain_password - - message = deferred_gettext("WIFI-Password was reset") - log_user_event(author=processor, - user=user, - message=message.to_json()) - - return plain_password - - -def maybe_setup_wifi(user: User, processor: User) -> str | None: - """If wifi is available, sets a wifi password.""" - if user.room and user.room.building.wifi_available: - return reset_wifi_password(user, processor) - return None - - -@with_transaction -def change_password(user: User, password: str) -> None: - # TODO: verify password complexity - user.password = password - - message = deferred_gettext("Password was changed") - log_user_event(author=user, - user=user, - message=message.to_json()) - - -def generate_wifi_password() -> str: - return user_helper.generate_password(12) - - def create_user( name: str, login: str, email: str, birthdate: date, groups: t.Iterable[PropertyGroup], processor: User | None, address: Address, @@ -1027,22 +976,6 @@ def send_password_reset_mail(user: User) -> bool: return True -@with_transaction -def change_password_from_token(token: str | None, password: str) -> bool: - if token is None: - return False - - user = User.q.filter_by(password_reset_token=token).one_or_none() - - if user: - change_password(user, password) - user.password_reset_token = None - user.email_confirmed = True - - return True - else: - return False - def find_similar_users(name: str, room: Room, ratio: float) -> Iterable[User]: """Given a potential user's name and a room, find users of similar name living in that room. diff --git a/pycroft/lib/user/passwords.py b/pycroft/lib/user/passwords.py new file mode 100644 index 000000000..9cd07fa4a --- /dev/null +++ b/pycroft/lib/user/passwords.py @@ -0,0 +1,72 @@ +from pycroft.helpers import user as user_helper +from pycroft.helpers.i18n import deferred_gettext +from pycroft.lib.logging import log_user_event +from pycroft.model.session import with_transaction +from pycroft.model.user import User + +from ._old import can_target + + + +def maybe_setup_wifi(user: User, processor: User) -> str | None: + """If wifi is available, sets a wifi password.""" + if user.room and user.room.building.wifi_available: + return reset_wifi_password(user, processor) + return None + + +@with_transaction +def change_password(user: User, password: str) -> None: + # TODO: verify password complexity + user.password = password + + message = deferred_gettext("Password was changed") + log_user_event(author=user, user=user, message=message.to_json()) + + +@with_transaction +def reset_password(user: User, processor: User) -> str: + if not can_target(user, processor): + raise PermissionError( + "cannot reset password of a user with a" " greater or equal permission level." + ) + + plain_password = user_helper.generate_password(12) + user.password = plain_password + + message = deferred_gettext("Password was reset") + log_user_event(author=processor, user=user, message=message.to_json()) + + return plain_password + + +@with_transaction +def reset_wifi_password(user: User, processor: User) -> str: + plain_password = generate_wifi_password() + user.wifi_password = plain_password + + message = deferred_gettext("WIFI-Password was reset") + log_user_event(author=processor, user=user, message=message.to_json()) + + return plain_password + + +@with_transaction +def change_password_from_token(token: str | None, password: str) -> bool: + if token is None: + return False + + user = User.q.filter_by(password_reset_token=token).one_or_none() + + if user: + change_password(user, password) + user.password_reset_token = None + user.email_confirmed = True + + return True + else: + return False + + +def generate_wifi_password() -> str: + return user_helper.generate_password(12) From d765be1df3573d106ec18f6f35bd5ccc8b277a53 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Wed, 4 Sep 2024 16:14:59 +0200 Subject: [PATCH 08/32] extract `lib.user.mail` --- pycroft/lib/user/__init__.py | 17 ++-- pycroft/lib/user/_old.py | 143 +------------------------------ pycroft/lib/user/mail.py | 161 +++++++++++++++++++++++++++++++++++ tests/lib/user/conftest.py | 2 +- 4 files changed, 174 insertions(+), 149 deletions(-) create mode 100644 pycroft/lib/user/mail.py diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index 7f80103da..21cfed8e5 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -22,13 +22,6 @@ membership_end_date, membership_beginning_task, membership_begin_date, - format_user_mail, - user_send_mails, - user_send_mail, - get_active_users, - group_send_mail, - send_member_request_merged_email, - send_confirmation_email, get_similar_users_in_room, check_similar_user_in_room, get_user_by_swdd_person_id, @@ -71,7 +64,15 @@ check_new_user_data, check_new_user_data_unused, ) - +from .mail import ( + format_user_mail, + user_send_mails, + user_send_mail, + get_active_users, + group_send_mail, + send_member_request_merged_email, + send_confirmation_email, +) from .mail_confirmation import ( confirm_mail_address, ) diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index 00b07c22d..83e2cc9b5 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -17,7 +17,7 @@ from difflib import SequenceMatcher from collections.abc import Iterable -from sqlalchemy import exists, func, select, Boolean, String, ColumnElement, ScalarResult +from sqlalchemy import exists, func, select, Boolean, String, ColumnElement from sqlalchemy.orm import Session from pycroft import config, property @@ -31,12 +31,8 @@ from pycroft.lib.finance import user_has_paid from pycroft.lib.logging import log_user_event from pycroft.lib.mail import ( - MailTemplate, - Mail, - UserConfirmEmailTemplate, UserCreatedTemplate, UserMovedInTemplate, - MemberRequestMergedTemplate, UserResetPasswordTemplate, ) from pycroft.lib.membership import make_member_of, remove_member_of @@ -56,15 +52,12 @@ from pycroft.model.traffic import traffic_history as func_traffic_history from pycroft.model.user import ( User, - PreMember, BaseUser, RoomHistoryEntry, PropertyGroup, - Membership, ) from pycroft.model.unix_account import UnixAccount, UnixTombstone from pycroft.model.webstorage import WebStorage -from pycroft.task import send_mails_async from .exc import LoginTakenException, UserExistsInRoomException from .user_id import ( @@ -74,8 +67,9 @@ check_user_id, ) from .passwords import generate_wifi_password +from .mail import user_send_mail, send_confirmation_email + -mail_confirm_url = os.getenv('MAIL_CONFIRM_URL') password_reset_url = os.getenv('PASSWORD_RESET_URL') @@ -767,137 +761,6 @@ def membership_begin_date(user: User) -> date | None: return end_date -def format_user_mail(user: User, text: str) -> str: - return text.format( - name=user.name, - login=user.login, - id=encode_type2_user_id(user.id), - email=user.email if user.email else '-', - email_internal=user.email_internal, - room_short=user.room.short_name - if user.room_id is not None else '-', - swdd_person_id=user.swdd_person_id - if user.swdd_person_id else '-', - ) - - -def user_send_mails( - users: t.Iterable[BaseUser], - template: MailTemplate | None = None, - soft_fail: bool = False, - use_internal: bool = True, - body_plain: str = None, - subject: str = None, - **kwargs: t.Any, -) -> None: - """ - Send a mail to a list of users - - :param users: Users who should receive the mail - :param template: The template that should be used. Can be None if body_plain is supplied. - :param soft_fail: Do not raise an exception if a user does not have an email and use_internal - is set to True - :param use_internal: If internal mail addresses can be used (@agdsn.me) - (Set to False to only send to external mail addresses) - :param body_plain: Alternative plain body if not template supplied - :param subject: Alternative subject if no template supplied - :param kwargs: kwargs that will be used during rendering the template - :return: - """ - - mails = [] - - for user in users: - if isinstance(user, User) and all((use_internal, - not (user.email_forwarded and user.email), - user.has_property('mail'))): - # Use internal email - email = user.email_internal - elif user.email: - # Use external email - email = user.email - else: - if soft_fail: - return - else: - raise ValueError("No contact email address available.") - - if template is not None: - # Template given, render... - plaintext, html = template.render(user=user, - user_id=encode_type2_user_id(user.id), - **kwargs) - subject = template.subject - else: - # No template given, use formatted body_mail instead. - if not isinstance(user, User): - raise ValueError("Plaintext email not supported for other User types.") - - html = None - plaintext = format_user_mail(user, body_plain) - - if plaintext is None or subject is None: - raise ValueError("No plain body supplied.") - - mail = Mail(to_name=user.name, - to_address=email, - subject=subject, - body_plain=plaintext, - body_html=html) - mails.append(mail) - - send_mails_async.delay(mails) - - -def user_send_mail( - user: BaseUser, - template: MailTemplate, - soft_fail: bool = False, - use_internal: bool = True, - **kwargs: t.Any, -) -> None: - user_send_mails([user], template, soft_fail, use_internal, **kwargs) - - -def get_active_users(session: Session, group: PropertyGroup) -> ScalarResult[User]: - return session.scalars( - select(User) - .join(User.current_memberships) - .where(Membership.group == group) - .distinct() - ) - - -def group_send_mail(group: PropertyGroup, subject: str, body_plain: str) -> None: - users = get_active_users(session=session.session, group=group) - user_send_mails(users, soft_fail=True, body_plain=body_plain, subject=subject) - - -def send_member_request_merged_email( - user: PreMember, merged_to: User, password_merged: bool -) -> None: - user_send_mail( - user, - MemberRequestMergedTemplate( - merged_to=merged_to, - merged_to_user_id=encode_type2_user_id(merged_to.id), - password_merged=password_merged, - ), - ) - - -@with_transaction -def send_confirmation_email(user: BaseUser) -> None: - user.email_confirmed = False - user.email_confirmation_key = generate_random_str(64) - - if not mail_confirm_url: - raise ValueError("No url specified in MAIL_CONFIRM_URL") - - user_send_mail(user, UserConfirmEmailTemplate( - email_confirm_url=mail_confirm_url.format(user.email_confirmation_key))) - - def get_similar_users_in_room(name: str, room: Room, ratio: float = 0.75) -> list[User]: """Get inhabitants of a room with a name similar to the given name. diff --git a/pycroft/lib/user/mail.py b/pycroft/lib/user/mail.py new file mode 100644 index 000000000..d166dc5c9 --- /dev/null +++ b/pycroft/lib/user/mail.py @@ -0,0 +1,161 @@ +import os +import typing as t + +from sqlalchemy import select, ScalarResult +from sqlalchemy.orm import Session + +from pycroft.helpers.user import generate_random_str +from pycroft.lib.mail import ( + MailTemplate, + Mail, + UserConfirmEmailTemplate, + MemberRequestMergedTemplate, +) +from pycroft.model import session +from pycroft.model.session import with_transaction +from pycroft.model.user import ( + User, + PreMember, + BaseUser, + PropertyGroup, + Membership, +) +from pycroft.task import send_mails_async + +from .user_id import ( + encode_type2_user_id, +) + +mail_confirm_url = os.getenv("MAIL_CONFIRM_URL") + + +def format_user_mail(user: User, text: str) -> str: + return text.format( + name=user.name, + login=user.login, + id=encode_type2_user_id(user.id), + email=user.email if user.email else "-", + email_internal=user.email_internal, + room_short=user.room.short_name if user.room_id is not None else "-", + swdd_person_id=user.swdd_person_id if user.swdd_person_id else "-", + ) + + +def user_send_mails( + users: t.Iterable[BaseUser], + template: MailTemplate | None = None, + soft_fail: bool = False, + use_internal: bool = True, + body_plain: str = None, + subject: str = None, + **kwargs: t.Any, +) -> None: + """ + Send a mail to a list of users + + :param users: Users who should receive the mail + :param template: The template that should be used. Can be None if body_plain is supplied. + :param soft_fail: Do not raise an exception if a user does not have an email and use_internal + is set to True + :param use_internal: If internal mail addresses can be used (@agdsn.me) + (Set to False to only send to external mail addresses) + :param body_plain: Alternative plain body if not template supplied + :param subject: Alternative subject if no template supplied + :param kwargs: kwargs that will be used during rendering the template + :return: + """ + + mails = [] + + for user in users: + if isinstance(user, User) and all( + (use_internal, not (user.email_forwarded and user.email), user.has_property("mail")) + ): + # Use internal email + email = user.email_internal + elif user.email: + # Use external email + email = user.email + else: + if soft_fail: + return + else: + raise ValueError("No contact email address available.") + + if template is not None: + # Template given, render... + plaintext, html = template.render( + user=user, user_id=encode_type2_user_id(user.id), **kwargs + ) + subject = template.subject + else: + # No template given, use formatted body_mail instead. + if not isinstance(user, User): + raise ValueError("Plaintext email not supported for other User types.") + + html = None + plaintext = format_user_mail(user, body_plain) + + if plaintext is None or subject is None: + raise ValueError("No plain body supplied.") + + mail = Mail( + to_name=user.name, + to_address=email, + subject=subject, + body_plain=plaintext, + body_html=html, + ) + mails.append(mail) + + send_mails_async.delay(mails) + + +def user_send_mail( + user: BaseUser, + template: MailTemplate, + soft_fail: bool = False, + use_internal: bool = True, + **kwargs: t.Any, +) -> None: + user_send_mails([user], template, soft_fail, use_internal, **kwargs) + + +def get_active_users(session: Session, group: PropertyGroup) -> ScalarResult[User]: + return session.scalars( + select(User).join(User.current_memberships).where(Membership.group == group).distinct() + ) + + +def group_send_mail(group: PropertyGroup, subject: str, body_plain: str) -> None: + users = get_active_users(session=session.session, group=group) + user_send_mails(users, soft_fail=True, body_plain=body_plain, subject=subject) + + +def send_member_request_merged_email( + user: PreMember, merged_to: User, password_merged: bool +) -> None: + user_send_mail( + user, + MemberRequestMergedTemplate( + merged_to=merged_to, + merged_to_user_id=encode_type2_user_id(merged_to.id), + password_merged=password_merged, + ), + ) + + +@with_transaction +def send_confirmation_email(user: BaseUser) -> None: + user.email_confirmed = False + user.email_confirmation_key = generate_random_str(64) + + if not mail_confirm_url: + raise ValueError("No url specified in MAIL_CONFIRM_URL") + + user_send_mail( + user, + UserConfirmEmailTemplate( + email_confirm_url=mail_confirm_url.format(user.email_confirmation_key) + ), + ) diff --git a/tests/lib/user/conftest.py b/tests/lib/user/conftest.py index 2f3d49911..26fdba076 100644 --- a/tests/lib/user/conftest.py +++ b/tests/lib/user/conftest.py @@ -13,5 +13,5 @@ def delay(mails): assert all(isinstance(m, Mail) for m in mails), "didn't get an instance of Mail()" mails_captured.extend(mails) - monkeypatch.setattr("pycroft.lib.user.send_mails_async", TaskStub) + monkeypatch.setattr("pycroft.lib.user.mail.send_mails_async", TaskStub) yield mails_captured From 8eb0331483879f8fde757a2d8d8ace4a01633d3e Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Wed, 4 Sep 2024 16:23:39 +0200 Subject: [PATCH 09/32] extract `can_target` to `.permissions` module --- pycroft/lib/user/__init__.py | 2 +- pycroft/lib/user/_old.py | 6 ------ pycroft/lib/user/edit.py | 3 ++- pycroft/lib/user/passwords.py | 3 +-- pycroft/lib/user/permission.py | 8 ++++++++ 5 files changed, 12 insertions(+), 10 deletions(-) create mode 100644 pycroft/lib/user/permission.py diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index 21cfed8e5..505d65a0e 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -2,7 +2,6 @@ setup_ipv4_networking, store_user_sheet, get_user_sheet, - can_target, create_user, login_available, move_in, @@ -76,6 +75,7 @@ from .mail_confirmation import ( confirm_mail_address, ) +from .permission import can_target from .exc import ( HostAliasExists, diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index 83e2cc9b5..3e595cd24 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -135,12 +135,6 @@ def get_user_sheet(sheet_id: int) -> bytes | None: return storage.data -def can_target(user: User, processor: User) -> bool: - if user != processor: - return user.permission_level < processor.permission_level - else: - return True - def create_user( name: str, login: str, email: str, birthdate: date, groups: t.Iterable[PropertyGroup], processor: User | None, address: Address, diff --git a/pycroft/lib/user/edit.py b/pycroft/lib/user/edit.py index f731d1e31..6dc4f8e95 100644 --- a/pycroft/lib/user/edit.py +++ b/pycroft/lib/user/edit.py @@ -7,7 +7,8 @@ from pycroft.model.session import with_transaction from pycroft.model.user import User -from ._old import can_target, send_confirmation_email +from ._old import send_confirmation_email +from .permission import can_target @with_transaction diff --git a/pycroft/lib/user/passwords.py b/pycroft/lib/user/passwords.py index 9cd07fa4a..a54932ba7 100644 --- a/pycroft/lib/user/passwords.py +++ b/pycroft/lib/user/passwords.py @@ -4,8 +4,7 @@ from pycroft.model.session import with_transaction from pycroft.model.user import User -from ._old import can_target - +from .permission import can_target def maybe_setup_wifi(user: User, processor: User) -> str | None: diff --git a/pycroft/lib/user/permission.py b/pycroft/lib/user/permission.py new file mode 100644 index 000000000..b8a0893d6 --- /dev/null +++ b/pycroft/lib/user/permission.py @@ -0,0 +1,8 @@ +from pycroft.model.user import User + + +def can_target(user: User, processor: User) -> bool: + if user != processor: + return user.permission_level < processor.permission_level + else: + return True From 3b8fda3428b6fbe97156d4ec60ae47c0d116c811 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Tue, 17 Sep 2024 14:43:35 +0200 Subject: [PATCH 10/32] Extract `lib.user.user_sheet` --- pycroft/lib/user/__init__.py | 8 ++- pycroft/lib/user/_old.py | 100 +----------------------------- pycroft/lib/user/user_sheet.py | 107 +++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 102 deletions(-) create mode 100644 pycroft/lib/user/user_sheet.py diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index 505d65a0e..d7afeb516 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -1,7 +1,5 @@ from ._old import ( setup_ipv4_networking, - store_user_sheet, - get_user_sheet, create_user, login_available, move_in, @@ -16,7 +14,6 @@ move_out, UserStatus, status, - generate_user_sheet, membership_ending_task, membership_end_date, membership_beginning_task, @@ -76,6 +73,11 @@ confirm_mail_address, ) from .permission import can_target +from .user_sheet import ( + generate_user_sheet, + get_user_sheet, + store_user_sheet, +) from .exc import ( HostAliasExists, diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index 3e595cd24..15ffadd9c 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -13,7 +13,7 @@ import re import typing import typing as t -from datetime import timedelta, date +from datetime import date from difflib import SequenceMatcher from collections.abc import Iterable @@ -24,7 +24,6 @@ from pycroft.helpers import user as user_helper from pycroft.helpers.i18n import deferred_gettext from pycroft.helpers.interval import closed, Interval, starting_from -from pycroft.helpers.printing import generate_user_sheet as generate_pdf from pycroft.helpers.user import generate_random_str, login_hash from pycroft.helpers.utc import DateTimeTz from pycroft.lib.facilities import get_room @@ -57,12 +56,10 @@ PropertyGroup, ) from pycroft.model.unix_account import UnixAccount, UnixTombstone -from pycroft.model.webstorage import WebStorage from .exc import LoginTakenException, UserExistsInRoomException from .user_id import ( decode_type1_user_id, - encode_type2_user_id, decode_type2_user_id, check_user_id, ) @@ -84,57 +81,6 @@ def setup_ipv4_networking(host: Host) -> None: session.session.add(new_ip) -def store_user_sheet( - new_user: bool, - wifi: bool, - user: User | None = None, - timeout: int = 15, - plain_user_password: str = None, - generation_purpose: str = "", - plain_wifi_password: str = "", -) -> WebStorage: - """Generate a user sheet and store it in the WebStorage. - - Returns the generated :class:`WebStorage ` object holding the pdf. - - :param new_user: generate page with user details - :param wifi: generate page with wifi credantials - :param user: A pycroft user. Necessary in every case - :param timeout: The lifetime in minutes - :param plain_user_password: Only necessary if ``new_user is True`` - :param plain_wifi_password: The password for wifi. Only necessary if ``wifi is True`` - :param generation_purpose: Optional - """ - - pdf_data = generate_user_sheet( - new_user, wifi, user, - plain_user_password=plain_user_password, - generation_purpose=generation_purpose, - plain_wifi_password=plain_wifi_password, - ) - - pdf_storage = WebStorage(data=pdf_data, - expiry=session.utcnow() + timedelta(minutes=timeout)) - session.session.add(pdf_storage) - - return pdf_storage - - -def get_user_sheet(sheet_id: int) -> bytes | None: - """Fetch the storage object given an id. - - If not existent, return None. - """ - WebStorage.auto_expire() - - if sheet_id is None: - return None - if (storage := session.session.get(WebStorage, sheet_id)) is None: - return None - - return storage.data - - def create_user( name: str, login: str, email: str, birthdate: date, groups: t.Iterable[PropertyGroup], processor: User | None, address: Address, @@ -650,50 +596,6 @@ def status(user: User) -> UserStatus: ) -def generate_user_sheet( - new_user: bool, - wifi: bool, - user: User | None = None, - plain_user_password: str | None = None, - generation_purpose: str = "", - plain_wifi_password: str = "", -) -> bytes: - """Create a new datasheet for the given user. - This usersheet can hold information about a user or about the wifi credentials of a user. - - This is a wrapper for - :py:func:`pycroft.helpers.printing.generate_user_sheet` equipping - it with the correct user id. - - This function cannot be exported to a `wrappers` module because it - depends on `encode_type2_user_id` and is required by - `(store|get)_user_sheet`, both in this module. - - :param new_user: Generate a page for a new created user - :param wifi: Generate a page with the wifi credantials - - Necessary in every case: - :param user: A pycroft user - - Only necessary if new_user=True: - :param plain_user_password: The password - - Only necessary if wifi=True: - :param generation_purpose: Optional purpose why this usersheet was printed - """ - from pycroft.helpers import printing - return generate_pdf( - new_user=new_user, - wifi=wifi, - bank_account=config.membership_fee_bank_account, - user=t.cast(printing.User, user), - user_id=encode_type2_user_id(user.id), - plain_user_password=plain_user_password, - generation_purpose=generation_purpose, - plain_wifi_password=plain_wifi_password, - ) - - def membership_ending_task(user: User) -> UserTask: """ :return: Next task that will end the membership of the user diff --git a/pycroft/lib/user/user_sheet.py b/pycroft/lib/user/user_sheet.py new file mode 100644 index 000000000..af899183d --- /dev/null +++ b/pycroft/lib/user/user_sheet.py @@ -0,0 +1,107 @@ +import typing as t +from datetime import timedelta + +from pycroft import config +from pycroft.helpers.printing import generate_user_sheet as generate_pdf +from pycroft.model import session +from pycroft.model.webstorage import WebStorage +from pycroft.model.user import User + +from .user_id import encode_type2_user_id + + +def store_user_sheet( + new_user: bool, + wifi: bool, + user: User | None = None, + timeout: int = 15, + plain_user_password: str = None, + generation_purpose: str = "", + plain_wifi_password: str = "", +) -> WebStorage: + """Generate a user sheet and store it in the WebStorage. + + Returns the generated :class:`WebStorage ` object holding the pdf. + + :param new_user: generate page with user details + :param wifi: generate page with wifi credantials + :param user: A pycroft user. Necessary in every case + :param timeout: The lifetime in minutes + :param plain_user_password: Only necessary if ``new_user is True`` + :param plain_wifi_password: The password for wifi. Only necessary if ``wifi is True`` + :param generation_purpose: Optional + """ + + pdf_data = generate_user_sheet( + new_user, + wifi, + user, + plain_user_password=plain_user_password, + generation_purpose=generation_purpose, + plain_wifi_password=plain_wifi_password, + ) + + pdf_storage = WebStorage(data=pdf_data, expiry=session.utcnow() + timedelta(minutes=timeout)) + session.session.add(pdf_storage) + + return pdf_storage + + +def get_user_sheet(sheet_id: int) -> bytes | None: + """Fetch the storage object given an id. + + If not existent, return None. + """ + WebStorage.auto_expire() + + if sheet_id is None: + return None + if (storage := session.session.get(WebStorage, sheet_id)) is None: + return None + + return storage.data + + +def generate_user_sheet( + new_user: bool, + wifi: bool, + user: User | None = None, + plain_user_password: str | None = None, + generation_purpose: str = "", + plain_wifi_password: str = "", +) -> bytes: + """Create a new datasheet for the given user. + This usersheet can hold information about a user or about the wifi credentials of a user. + + This is a wrapper for + :py:func:`pycroft.helpers.printing.generate_user_sheet` equipping + it with the correct user id. + + This function cannot be exported to a `wrappers` module because it + depends on `encode_type2_user_id` and is required by + `(store|get)_user_sheet`, both in this module. + + :param new_user: Generate a page for a new created user + :param wifi: Generate a page with the wifi credantials + + Necessary in every case: + :param user: A pycroft user + + Only necessary if new_user=True: + :param plain_user_password: The password + + Only necessary if wifi=True: + :param generation_purpose: Optional purpose why this usersheet was printed + """ + from pycroft.helpers import printing + + return generate_pdf( + new_user=new_user, + wifi=wifi, + bank_account=config.membership_fee_bank_account, + user=t.cast(printing.User, user), + user_id=encode_type2_user_id(user.id), + plain_user_password=plain_user_password, + generation_purpose=generation_purpose, + plain_wifi_password=plain_wifi_password, + ) From 4dec3dba4305ac146148c1e043220010cbfdb35e Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Tue, 17 Sep 2024 15:08:01 +0200 Subject: [PATCH 11/32] Move user identification heuristics to `lib.user.member_request` --- pycroft/lib/user/__init__.py | 14 ++--- pycroft/lib/user/_old.py | 94 +----------------------------- pycroft/lib/user/member_request.py | 93 +++++++++++++++++++++++++++-- 3 files changed, 97 insertions(+), 104 deletions(-) diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index d7afeb516..f47ef09c5 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -18,14 +18,7 @@ membership_end_date, membership_beginning_task, membership_begin_date, - get_similar_users_in_room, - check_similar_user_in_room, - get_user_by_swdd_person_id, - get_name_from_first_last, - get_user_by_id_or_login, send_password_reset_mail, - find_similar_users, - are_names_similar, ) from .user_id import ( encode_type1_user_id, @@ -59,6 +52,13 @@ get_possible_existing_users_for_pre_member, check_new_user_data, check_new_user_data_unused, + get_similar_users_in_room, + check_similar_user_in_room, + get_user_by_swdd_person_id, + get_name_from_first_last, + get_user_by_id_or_login, + find_similar_users, + are_names_similar, ) from .mail import ( format_user_mail, diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index 15ffadd9c..4e15c8b45 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -10,14 +10,10 @@ :copyright: (c) 2012 by AG DSN. """ import os -import re -import typing import typing as t from datetime import date -from difflib import SequenceMatcher -from collections.abc import Iterable -from sqlalchemy import exists, func, select, Boolean, String, ColumnElement +from sqlalchemy import exists, select, Boolean, String, ColumnElement from sqlalchemy.orm import Session from pycroft import config, property @@ -51,18 +47,11 @@ from pycroft.model.traffic import traffic_history as func_traffic_history from pycroft.model.user import ( User, - BaseUser, - RoomHistoryEntry, PropertyGroup, ) from pycroft.model.unix_account import UnixAccount, UnixTombstone -from .exc import LoginTakenException, UserExistsInRoomException -from .user_id import ( - decode_type1_user_id, - decode_type2_user_id, - check_user_id, -) +from .exc import LoginTakenException from .passwords import generate_wifi_password from .mail import user_send_mail, send_confirmation_email @@ -657,66 +646,6 @@ def membership_begin_date(user: User) -> date | None: return end_date -def get_similar_users_in_room(name: str, room: Room, ratio: float = 0.75) -> list[User]: - """Get inhabitants of a room with a name similar to the given name. - - Eagerloading hints: - - `room.users` - """ - - if room is None: - return [] - - return [user for user in room.users if SequenceMatcher(None, name, user.name).ratio() > ratio] - - -def check_similar_user_in_room(name: str, room: Room) -> None: - """ - Raise an error if an user with a 75% name match already exists in the room - """ - - if get_similar_users_in_room(name, room): - raise UserExistsInRoomException - - -def get_user_by_swdd_person_id(swdd_person_id: int | None) -> User | None: - if swdd_person_id is None: - return None - - return typing.cast( - User | None, - User.q.filter_by(swdd_person_id=swdd_person_id).first() - ) - - -def get_name_from_first_last(first_name: str, last_name: str) -> str: - return f"{first_name} {last_name}" if last_name else first_name - - -def get_user_by_id_or_login(ident: str, email: str) -> User | None: - re_uid1 = r"^\d{4,6}-\d{1}$" - re_uid2 = r"^\d{4,6}-\d{2}$" - - user = User.q.filter(func.lower(User.email) == email.lower()) - - if re.match(re_uid1, ident): - if not check_user_id(ident): - return None - user_id, _ = decode_type1_user_id(ident) - user = user.filter_by(id=user_id) - elif re.match(re_uid2, ident): - if not check_user_id(ident): - return None - user_id, _ = decode_type2_user_id(ident) - user = user.filter_by(id=user_id) - elif re.match(BaseUser.login_regex, ident): - user = user.filter_by(login=ident) - else: - return None - - return t.cast(User | None, user.one_or_none()) - - @with_transaction def send_password_reset_mail(user: User) -> bool: user.password_reset_token = generate_random_str(64) @@ -733,22 +662,3 @@ def send_password_reset_mail(user: User) -> bool: return False return True - - - -def find_similar_users(name: str, room: Room, ratio: float) -> Iterable[User]: - """Given a potential user's name and a room, find users of similar name living in that room. - - :param name: The potential user's name - :param room: the room whose inhabitants to search - :param ratio: the threshold which determines which matches are included in this list. - For that, the `difflib.SequenceMatcher.ratio` must be greater than the given value. - """ - relevant_users_q = (session.session.query(User) - .join(RoomHistoryEntry) - .filter(RoomHistoryEntry.room == room)) - return [u for u in relevant_users_q if are_names_similar(name, u.name, threshold=ratio)] - - -def are_names_similar(one: str, other: str, threshold: float) -> bool: - return SequenceMatcher(a=one, b=other).ratio() > threshold diff --git a/pycroft/lib/user/member_request.py b/pycroft/lib/user/member_request.py index f11bf904a..6662ce871 100644 --- a/pycroft/lib/user/member_request.py +++ b/pycroft/lib/user/member_request.py @@ -1,4 +1,7 @@ +import re +import typing as t from datetime import timedelta, date +from difflib import SequenceMatcher from sqlalchemy import func @@ -14,18 +17,17 @@ from pycroft.model.facilities import Room from pycroft.model.session import with_transaction from pycroft.model.user import ( + BaseUser, User, PreMember, + RoomHistoryEntry, ) from ._old import ( create_user, + login_available, move_in, move, - get_user_by_swdd_person_id, - get_similar_users_in_room, - check_similar_user_in_room, - login_available, send_confirmation_email, user_send_mail, ) @@ -39,10 +41,16 @@ LoginTakenException, EmailTakenException, UserExistsException, + UserExistsInRoomException, NoTenancyForRoomException, MoveInDateInvalidException, ) -from .user_id import encode_type2_user_id +from .user_id import ( + check_user_id, + decode_type1_user_id, + decode_type2_user_id, + encode_type2_user_id, +) @with_transaction @@ -336,3 +344,78 @@ def check_new_user_data_unused(login: str, email: str, swdd_person_id: int) -> N raise EmailTakenException return + + +def get_similar_users_in_room(name: str, room: Room, ratio: float = 0.75) -> list[User]: + """Get inhabitants of a room with a name similar to the given name. + + Eagerloading hints: + - `room.users` + """ + + if room is None: + return [] + + return [user for user in room.users if SequenceMatcher(None, name, user.name).ratio() > ratio] + + +def check_similar_user_in_room(name: str, room: Room) -> None: + """ + Raise an error if an user with a 75% name match already exists in the room + """ + + if get_similar_users_in_room(name, room): + raise UserExistsInRoomException + + +def get_user_by_swdd_person_id(swdd_person_id: int | None) -> User | None: + if swdd_person_id is None: + return None + + return t.cast(User | None, User.q.filter_by(swdd_person_id=swdd_person_id).first()) + + +def get_name_from_first_last(first_name: str, last_name: str) -> str: + return f"{first_name} {last_name}" if last_name else first_name + + +def get_user_by_id_or_login(ident: str, email: str) -> User | None: + re_uid1 = r"^\d{4,6}-\d{1}$" + re_uid2 = r"^\d{4,6}-\d{2}$" + + user = User.q.filter(func.lower(User.email) == email.lower()) + + if re.match(re_uid1, ident): + if not check_user_id(ident): + return None + user_id, _ = decode_type1_user_id(ident) + user = user.filter_by(id=user_id) + elif re.match(re_uid2, ident): + if not check_user_id(ident): + return None + user_id, _ = decode_type2_user_id(ident) + user = user.filter_by(id=user_id) + elif re.match(BaseUser.login_regex, ident): + user = user.filter_by(login=ident) + else: + return None + + return t.cast(User | None, user.one_or_none()) + + +def find_similar_users(name: str, room: Room, ratio: float) -> t.Iterable[User]: + """Given a potential user's name and a room, find users of similar name living in that room. + + :param name: The potential user's name + :param room: the room whose inhabitants to search + :param ratio: the threshold which determines which matches are included in this list. + For that, the `difflib.SequenceMatcher.ratio` must be greater than the given value. + """ + relevant_users_q = ( + session.session.query(User).join(RoomHistoryEntry).filter(RoomHistoryEntry.room == room) + ) + return [u for u in relevant_users_q if are_names_similar(name, u.name, threshold=ratio)] + + +def are_names_similar(one: str, other: str, threshold: float) -> bool: + return SequenceMatcher(a=one, b=other).ratio() > threshold From d61e72cf8a1fb4d13946238f7999aa7cbfccd0fb Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Tue, 17 Sep 2024 15:26:42 +0200 Subject: [PATCH 12/32] Extract `lib.user.info` This also removes the unused `has_positive_balance` function (unused as a template filter) --- pycroft/lib/user/__init__.py | 10 ++--- pycroft/lib/user/_old.py | 73 +----------------------------------- pycroft/lib/user/info.py | 54 ++++++++++++++++++++++++++ web/template_tests.py | 8 ---- 4 files changed, 61 insertions(+), 84 deletions(-) create mode 100644 pycroft/lib/user/info.py diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index f47ef09c5..f75a7a80c 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -5,15 +5,10 @@ move_in, migrate_user_host, move, - traffic_history, - has_balance_of_at_least, - has_positive_balance, get_blocked_groups, block, unblock, move_out, - UserStatus, - status, membership_ending_task, membership_end_date, membership_beginning_task, @@ -34,6 +29,11 @@ edit_person_id, edit_address, ) +from .info import ( + UserStatus, + status, + traffic_history, +) from .passwords import ( maybe_setup_wifi, reset_password, diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index 4e15c8b45..ef9d2bc33 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -13,17 +13,16 @@ import typing as t from datetime import date -from sqlalchemy import exists, select, Boolean, String, ColumnElement +from sqlalchemy import exists, select, Boolean, String from sqlalchemy.orm import Session -from pycroft import config, property +from pycroft import config from pycroft.helpers import user as user_helper from pycroft.helpers.i18n import deferred_gettext from pycroft.helpers.interval import closed, Interval, starting_from from pycroft.helpers.user import generate_random_str, login_hash from pycroft.helpers.utc import DateTimeTz from pycroft.lib.facilities import get_room -from pycroft.lib.finance import user_has_paid from pycroft.lib.logging import log_user_event from pycroft.lib.mail import ( UserCreatedTemplate, @@ -43,8 +42,6 @@ from pycroft.model.task import TaskType, UserTask, TaskStatus from pycroft.model.task_serialization import UserMoveParams, UserMoveOutParams, \ UserMoveInParams -from pycroft.model.traffic import TrafficHistoryEntry -from pycroft.model.traffic import traffic_history as func_traffic_history from pycroft.model.user import ( User, PropertyGroup, @@ -375,43 +372,6 @@ def move( return user -def traffic_history( - user_id: int, - start: DateTimeTz | ColumnElement[DateTimeTz], - end: DateTimeTz | ColumnElement[DateTimeTz], -) -> list[TrafficHistoryEntry]: - result = session.session.execute( - select("*") - .select_from(func_traffic_history(user_id, start, end)) - ).fetchall() - return [TrafficHistoryEntry(**row._asdict()) for row in result] - - -def has_balance_of_at_least(user: User, amount: int) -> bool: - """Check whether the given user's balance is at least the given - amount. - - If a user does not have an account, we treat his balance as if it - were exactly zero. - - :param user: The user we are interested in. - :param amount: The amount we want to check for. - :return: True if and only if the user's balance is at least the given - amount (and False otherwise). - """ - balance = t.cast(int, user.account.balance if user.account else 0) - return balance >= amount - - -def has_positive_balance(user: User) -> bool: - """Check whether the given user's balance is (weakly) positive. - - :param user: The user we are interested in. - :return: True if and only if the user's balance is at least zero. - """ - return has_balance_of_at_least(user, 0) - - def get_blocked_groups() -> list[PropertyGroup]: return [config.violation_group, config.payment_in_default_group, config.blocked_group] @@ -556,35 +516,6 @@ def move_out( return user -admin_properties = property.property_categories["Nutzerverwaltung"].keys() - - -class UserStatus(t.NamedTuple): - member: bool - traffic_exceeded: bool - network_access: bool - wifi_access: bool - account_balanced: bool - violation: bool - ldap: bool - admin: bool - - -def status(user: User) -> UserStatus: - has_interface = any(h.interfaces for h in user.hosts) - has_access = user.has_property("network_access") - return UserStatus( - member=user.has_property("member"), - traffic_exceeded=user.has_property("traffic_limit_exceeded"), - network_access=has_access and has_interface, - wifi_access=user.has_wifi_access and has_access, - account_balanced=user_has_paid(user), - violation=user.has_property("violation"), - ldap=user.has_property("ldap"), - admin=any(prop in user.current_properties for prop in admin_properties), - ) - - def membership_ending_task(user: User) -> UserTask: """ :return: Next task that will end the membership of the user diff --git a/pycroft/lib/user/info.py b/pycroft/lib/user/info.py new file mode 100644 index 000000000..9832aae22 --- /dev/null +++ b/pycroft/lib/user/info.py @@ -0,0 +1,54 @@ +import typing as t + + +from sqlalchemy import select, ColumnElement + +from pycroft import property +from pycroft.helpers.utc import DateTimeTz +from pycroft.model import session +from pycroft.model.traffic import TrafficHistoryEntry +from pycroft.model.traffic import traffic_history as func_traffic_history +from pycroft.model.user import ( + User, +) +from pycroft.lib.finance import user_has_paid + + +class UserStatus(t.NamedTuple): + member: bool + traffic_exceeded: bool + network_access: bool + wifi_access: bool + account_balanced: bool + violation: bool + ldap: bool + admin: bool + + +def status(user: User) -> UserStatus: + has_interface = any(h.interfaces for h in user.hosts) + has_access = user.has_property("network_access") + return UserStatus( + member=user.has_property("member"), + traffic_exceeded=user.has_property("traffic_limit_exceeded"), + network_access=has_access and has_interface, + wifi_access=user.has_wifi_access and has_access, + account_balanced=user_has_paid(user), + violation=user.has_property("violation"), + ldap=user.has_property("ldap"), + admin=any(prop in user.current_properties for prop in _admin_properties), + ) + + +_admin_properties = property.property_categories["Nutzerverwaltung"].keys() + + +def traffic_history( + user_id: int, + start: DateTimeTz | ColumnElement[DateTimeTz], + end: DateTimeTz | ColumnElement[DateTimeTz], +) -> list[TrafficHistoryEntry]: + result = session.session.execute( + select("*").select_from(func_traffic_history(user_id, start, end)) + ).fetchall() + return [TrafficHistoryEntry(**row._asdict()) for row in result] diff --git a/web/template_tests.py b/web/template_tests.py index efeb61e4e..626946af6 100644 --- a/web/template_tests.py +++ b/web/template_tests.py @@ -5,7 +5,6 @@ from flask import Flask -from pycroft.lib.user import has_positive_balance from pycroft.model.user import User _check_registry: dict[str, t.Callable] = {} @@ -21,13 +20,6 @@ def decorator(fn: _T) -> _T: return decorator -@template_check("user_with_positive_balance") -def positive_balance_check(user: User) -> bool: - """Tests if user has a positive balance - """ - return has_positive_balance(user) - - @template_check("user_with_no_network_access") def no_network_access_check(user: User) -> bool: """Tests if user has network access From d28c9911f9d2cb457a8f833f08f572bd973f156a Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 10:58:44 +0200 Subject: [PATCH 13/32] Migrate `lib.user.migrate_user_host` to `lib.host.migrate_host` --- pycroft/lib/host.py | 42 +++++++++++++++++++++++++++++-- pycroft/lib/user/__init__.py | 2 -- pycroft/lib/user/_old.py | 49 +++--------------------------------- 3 files changed, 43 insertions(+), 50 deletions(-) diff --git a/pycroft/lib/host.py b/pycroft/lib/host.py index 666256144..fd9c47d34 100644 --- a/pycroft/lib/host.py +++ b/pycroft/lib/host.py @@ -15,7 +15,6 @@ from pycroft.helpers.net import port_name_sort_key from pycroft.lib.logging import log_user_event from pycroft.lib.net import get_subnets_for_room, get_free_ip, delete_ip -from pycroft.lib.user import migrate_user_host from pycroft.model.facilities import Room from pycroft.model.host import Interface, IP, Host, SwitchPort from pycroft.model.port import PatchPort @@ -95,7 +94,46 @@ def host_edit(host: Host, owner: User, room: Room, name: str, processor: User) - host.owner = owner if host.room != room: - migrate_user_host(host, room, processor) + migrate_host(host, room, processor) + + +def migrate_host(host: Host, new_room: Room, processor: User) -> None: + """ + Migrate a Host to a new room and if necessary to a new subnet. + If the host changes subnet, it will get a new IP address. + + :param host: Host to be migrated + :param new_room: new room of the host + :param processor: User processing the migration + :return: + """ + old_room = host.room + host.room = new_room + + subnets_old = get_subnets_for_room(old_room) + subnets = get_subnets_for_room(new_room) + + if subnets_old != subnets: + for interface in host.interfaces: + old_ips = tuple(ip for ip in interface.ips) + for old_ip in old_ips: + ip_address, subnet = get_free_ip(subnets) + new_ip = IP(interface=interface, address=ip_address, subnet=subnet) + session.add(new_ip) + + old_address = old_ip.address + session.delete(old_ip) + + message = deferred_gettext("Changed IP of {mac} from {old_ip} to {new_ip}.").format( + old_ip=str(old_address), new_ip=str(new_ip.address), mac=interface.mac + ) + log_user_event(author=processor, user=host.owner, message=message.to_json()) + + message = deferred_gettext("Moved host '{name}' from {room_old} to {room_new}.").format( + name=host.name, room_old=old_room.short_name, room_new=new_room.short_name + ) + + log_user_event(author=processor, user=host.owner, message=message.to_json()) @with_transaction diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index f75a7a80c..3573d96e0 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -1,9 +1,7 @@ from ._old import ( - setup_ipv4_networking, create_user, login_available, move_in, - migrate_user_host, move, get_blocked_groups, block, diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index ef9d2bc33..0140412b1 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -23,6 +23,7 @@ from pycroft.helpers.user import generate_random_str, login_hash from pycroft.helpers.utc import DateTimeTz from pycroft.lib.facilities import get_room +from pycroft.lib.host import migrate_host from pycroft.lib.logging import log_user_event from pycroft.lib.mail import ( UserCreatedTemplate, @@ -235,7 +236,7 @@ def move_in( host_existing.owner_id = user.id session.session.add(host_existing) - migrate_user_host(host_existing, user.room, processor) + migrate_host(host_existing, user.room, processor) else: raise MacExistsException else: @@ -255,50 +256,6 @@ def move_in( return user -def migrate_user_host(host: Host, new_room: Room, processor: User) -> None: - """ - Migrate a UserHost to a new room and if necessary to a new subnet. - If the host changes subnet, it will get a new IP address. - - :param host: Host to be migrated - :param new_room: new room of the host - :param processor: User processing the migration - :return: - """ - old_room = host.room - host.room = new_room - - subnets_old = get_subnets_for_room(old_room) - subnets = get_subnets_for_room(new_room) - - if subnets_old != subnets: - for interface in host.interfaces: - old_ips = tuple(ip for ip in interface.ips) - for old_ip in old_ips: - ip_address, subnet = get_free_ip(subnets) - new_ip = IP(interface=interface, address=ip_address, subnet=subnet) - session.session.add(new_ip) - - old_address = old_ip.address - session.session.delete(old_ip) - - message = deferred_gettext("Changed IP of {mac} from {old_ip} to {new_ip}.").format( - old_ip=str(old_address), new_ip=str(new_ip.address), mac=interface.mac) - log_user_event(author=processor, user=host.owner, - message=message.to_json()) - - message = ( - deferred_gettext("Moved host '{name}' from {room_old} to {room_new}.") - .format( - name=host.name, room_old=old_room.short_name, room_new=new_room.short_name - ) - ) - - log_user_event(author=processor, - user=host.owner, - message=message.to_json()) - - #TODO ensure serializability def move( user: User, @@ -365,7 +322,7 @@ def move( for user_host in user.hosts: if user_host.room == old_room: - migrate_user_host(user_host, new_room, processor) + migrate_host(user_host, new_room, processor) user_send_mail(user, UserMovedInTemplate(), True) From 5e4c79645d331188f47459ec75a3109a48bbc5c0 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 11:09:07 +0200 Subject: [PATCH 14/32] Explicitly pass `session.session` in `migrate_host` --- pycroft/lib/host.py | 4 ++-- pycroft/lib/user/_old.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pycroft/lib/host.py b/pycroft/lib/host.py index fd9c47d34..07d62af74 100644 --- a/pycroft/lib/host.py +++ b/pycroft/lib/host.py @@ -94,10 +94,10 @@ def host_edit(host: Host, owner: User, room: Room, name: str, processor: User) - host.owner = owner if host.room != room: - migrate_host(host, room, processor) + migrate_host(session, host, room, processor) -def migrate_host(host: Host, new_room: Room, processor: User) -> None: +def migrate_host(session: Session, host: Host, new_room: Room, processor: User) -> None: """ Migrate a Host to a new room and if necessary to a new subnet. If the host changes subnet, it will get a new IP address. diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index 0140412b1..0cc5033c7 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -236,7 +236,7 @@ def move_in( host_existing.owner_id = user.id session.session.add(host_existing) - migrate_host(host_existing, user.room, processor) + migrate_host(session.session, host_existing, user.room, processor) else: raise MacExistsException else: @@ -322,7 +322,7 @@ def move( for user_host in user.hosts: if user_host.room == old_room: - migrate_host(user_host, new_room, processor) + migrate_host(session.session, user_host, new_room, processor) user_send_mail(user, UserMovedInTemplate(), True) From e7a84c3ec4493e036987743053a20706ed32b83a Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 11:35:53 +0200 Subject: [PATCH 15/32] Migrate `lib.user.setup_ipv4_networking` to `lib.host` --- pycroft/lib/host.py | 10 ++++++++++ pycroft/lib/user/_old.py | 20 ++++---------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/pycroft/lib/host.py b/pycroft/lib/host.py index 07d62af74..c85f4516c 100644 --- a/pycroft/lib/host.py +++ b/pycroft/lib/host.py @@ -272,3 +272,13 @@ def get_conflicting_interface( if new_mac == current_mac: return None return session.scalar(select(Interface).filter_by(mac=new_mac)) + + +def setup_ipv4_networking(session: Session, host: Host) -> None: + """Add suitable ips for every interface of a host""" + subnets = get_subnets_for_room(host.room) + + for interface in host.interfaces: + ip_address, subnet = get_free_ip(subnets) + new_ip = IP(interface=interface, address=ip_address, subnet=subnet) + session.add(new_ip) diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index 0cc5033c7..31c51921d 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -23,7 +23,7 @@ from pycroft.helpers.user import generate_random_str, login_hash from pycroft.helpers.utc import DateTimeTz from pycroft.lib.facilities import get_room -from pycroft.lib.host import migrate_host +from pycroft.lib.host import migrate_host, setup_ipv4_networking from pycroft.lib.logging import log_user_event from pycroft.lib.mail import ( UserCreatedTemplate, @@ -31,14 +31,13 @@ UserResetPasswordTemplate, ) from pycroft.lib.membership import make_member_of, remove_member_of -from pycroft.lib.net import get_free_ip, MacExistsException, \ - get_subnets_for_room +from pycroft.lib.net import MacExistsException from pycroft.lib.task import schedule_user_task from pycroft.model import session from pycroft.model.address import Address from pycroft.model.facilities import Room from pycroft.model.finance import Account -from pycroft.model.host import IP, Host, Interface +from pycroft.model.host import Host, Interface from pycroft.model.session import with_transaction from pycroft.model.task import TaskType, UserTask, TaskStatus from pycroft.model.task_serialization import UserMoveParams, UserMoveOutParams, \ @@ -57,17 +56,6 @@ password_reset_url = os.getenv('PASSWORD_RESET_URL') -def setup_ipv4_networking(host: Host) -> None: - """Add suitable ips for every interface of a host""" - subnets = get_subnets_for_room(host.room) - - for interface in host.interfaces: - ip_address, subnet = get_free_ip(subnets) - new_ip = IP(interface=interface, address=ip_address, - subnet=subnet) - session.session.add(new_ip) - - def create_user( name: str, login: str, email: str, birthdate: date, groups: t.Iterable[PropertyGroup], processor: User | None, address: Address, @@ -243,7 +231,7 @@ def move_in( new_host = Host(owner=user, room=room) session.session.add(new_host) session.session.add(Interface(mac=mac, host=new_host)) - setup_ipv4_networking(new_host) + setup_ipv4_networking(session.session, new_host) user_send_mail(user, UserMovedInTemplate(), True) From 96a7d4cfbdd9f1a2404a6a04ade5ded48c1b266a Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 11:54:50 +0200 Subject: [PATCH 16/32] Move `membership_{begin,end}_date` to `lib.user.info` --- pycroft/lib/user/__init__.py | 8 ++--- pycroft/lib/user/_old.py | 65 ++---------------------------------- pycroft/lib/user/info.py | 60 +++++++++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 69 deletions(-) diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index 3573d96e0..f1f3dc6b9 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -7,10 +7,6 @@ block, unblock, move_out, - membership_ending_task, - membership_end_date, - membership_beginning_task, - membership_begin_date, send_password_reset_mail, ) from .user_id import ( @@ -31,6 +27,10 @@ UserStatus, status, traffic_history, + membership_end_date, + membership_begin_date, + membership_ending_task, + membership_beginning_task, ) from .passwords import ( maybe_setup_wifi, diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index 31c51921d..6c716988f 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -13,7 +13,7 @@ import typing as t from datetime import date -from sqlalchemy import exists, select, Boolean, String +from sqlalchemy import exists, select from sqlalchemy.orm import Session from pycroft import config @@ -39,7 +39,7 @@ from pycroft.model.finance import Account from pycroft.model.host import Host, Interface from pycroft.model.session import with_transaction -from pycroft.model.task import TaskType, UserTask, TaskStatus +from pycroft.model.task import TaskType, UserTask from pycroft.model.task_serialization import UserMoveParams, UserMoveOutParams, \ UserMoveInParams from pycroft.model.user import ( @@ -461,67 +461,6 @@ def move_out( return user -def membership_ending_task(user: User) -> UserTask: - """ - :return: Next task that will end the membership of the user - """ - - return t.cast( - UserTask, - UserTask.q.filter_by( - user_id=user.id, status=TaskStatus.OPEN, type=TaskType.USER_MOVE_OUT - ) - # Casting jsonb -> bool directly is only supported since PG v11 - .filter( - UserTask.parameters_json["end_membership"].cast(String).cast(Boolean) - ) - .order_by(UserTask.due.asc()) - .first(), - ) - - -def membership_end_date(user: User) -> date | None: - """ - :return: The due date of the task that will end the membership; None if not - existent - """ - - ending_task = membership_ending_task(user) - - end_date = None if ending_task is None else ending_task.due.date() - - return end_date - - -def membership_beginning_task(user: User) -> UserTask: - """ - :return: Next task that will end the membership of the user - """ - - return t.cast( - UserTask, - UserTask.q.filter_by( - user_id=user.id, status=TaskStatus.OPEN, type=TaskType.USER_MOVE_IN - ) - .filter(UserTask.parameters_json["begin_membership"].cast(Boolean)) - .order_by(UserTask.due.asc()) - .first(), - ) - - -def membership_begin_date(user: User) -> date | None: - """ - :return: The due date of the task that will begin a membership; None if not - existent - """ - - begin_task = membership_beginning_task(user) - - end_date = None if begin_task is None else begin_task.due.date() - - return end_date - - @with_transaction def send_password_reset_mail(user: User) -> bool: user.password_reset_token = generate_random_str(64) diff --git a/pycroft/lib/user/info.py b/pycroft/lib/user/info.py index 9832aae22..d5afdb804 100644 --- a/pycroft/lib/user/info.py +++ b/pycroft/lib/user/info.py @@ -1,11 +1,12 @@ import typing as t +from datetime import date - -from sqlalchemy import select, ColumnElement +from sqlalchemy import select, ColumnElement, Boolean, String from pycroft import property from pycroft.helpers.utc import DateTimeTz from pycroft.model import session +from pycroft.model.task import TaskStatus, TaskType, UserTask from pycroft.model.traffic import TrafficHistoryEntry from pycroft.model.traffic import traffic_history as func_traffic_history from pycroft.model.user import ( @@ -52,3 +53,58 @@ def traffic_history( select("*").select_from(func_traffic_history(user_id, start, end)) ).fetchall() return [TrafficHistoryEntry(**row._asdict()) for row in result] + + +def membership_begin_date(user: User) -> date | None: + """ + :return: The due date of the task that will begin a membership; None if not + existent + """ + + begin_task = membership_beginning_task(user) + + end_date = None if begin_task is None else begin_task.due.date() + + return end_date + + +def membership_end_date(user: User) -> date | None: + """ + :return: The due date of the task that will end the membership; None if not + existent + """ + + ending_task = membership_ending_task(user) + + end_date = None if ending_task is None else ending_task.due.date() + + return end_date + + +def membership_beginning_task(user: User) -> UserTask: + """ + :return: Next task that will end the membership of the user + """ + + return t.cast( + UserTask, + UserTask.q.filter_by(user_id=user.id, status=TaskStatus.OPEN, type=TaskType.USER_MOVE_IN) + .filter(UserTask.parameters_json["begin_membership"].cast(Boolean)) + .order_by(UserTask.due.asc()) + .first(), + ) + + +def membership_ending_task(user: User) -> UserTask: + """ + :return: Next task that will end the membership of the user + """ + + return t.cast( + UserTask, + UserTask.q.filter_by(user_id=user.id, status=TaskStatus.OPEN, type=TaskType.USER_MOVE_OUT) + # Casting jsonb -> bool directly is only supported since PG v11 + .filter(UserTask.parameters_json["end_membership"].cast(String).cast(Boolean)) + .order_by(UserTask.due.asc()) + .first(), + ) From 6cac4422716ff2954d6c8b2df8d6c5e4f936f7a4 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 11:57:46 +0200 Subject: [PATCH 17/32] Improve naming of `membership_*_date` functions --- pycroft/lib/user/__init__.py | 4 ++-- pycroft/lib/user/info.py | 4 ++-- web/api/v0/__init__.py | 41 +++++++++++++++++++++++++----------- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index f1f3dc6b9..24309df57 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -27,8 +27,8 @@ UserStatus, status, traffic_history, - membership_end_date, - membership_begin_date, + scheduled_membership_end, + scheduled_membership_start, membership_ending_task, membership_beginning_task, ) diff --git a/pycroft/lib/user/info.py b/pycroft/lib/user/info.py index d5afdb804..930e1d057 100644 --- a/pycroft/lib/user/info.py +++ b/pycroft/lib/user/info.py @@ -55,7 +55,7 @@ def traffic_history( return [TrafficHistoryEntry(**row._asdict()) for row in result] -def membership_begin_date(user: User) -> date | None: +def scheduled_membership_start(user: User) -> date | None: """ :return: The due date of the task that will begin a membership; None if not existent @@ -68,7 +68,7 @@ def membership_begin_date(user: User) -> date | None: return end_date -def membership_end_date(user: User) -> date | None: +def scheduled_membership_end(user: User) -> date | None: """ :return: The due date of the task that will end the membership; None if not existent diff --git a/web/api/v0/__init__.py b/web/api/v0/__init__.py index 70b036c07..28f20989f 100644 --- a/web/api/v0/__init__.py +++ b/web/api/v0/__init__.py @@ -22,16 +22,33 @@ from pycroft.lib.swdd import get_swdd_person_id, get_relevant_tenancies, \ get_first_tenancy_with_room from pycroft.lib.task import cancel_task -from pycroft.lib.user import encode_type2_user_id, edit_email, change_password, \ - status, traffic_history as func_traffic_history, membership_end_date, \ - move_out, membership_ending_task, reset_wifi_password, \ - create_member_request, \ - NoTenancyForRoomException, UserExistsException, UserExistsInRoomException, \ - EmailTakenException, \ - LoginTakenException, MoveInDateInvalidException, check_similar_user_in_room, \ - get_name_from_first_last, confirm_mail_address, get_user_by_swdd_person_id, \ - membership_begin_date, send_confirmation_email, get_user_by_id_or_login, \ - send_password_reset_mail, change_password_from_token +from pycroft.lib.user import ( + encode_type2_user_id, + edit_email, + change_password, + status, + traffic_history as func_traffic_history, + scheduled_membership_end, + move_out, + membership_ending_task, + reset_wifi_password, + create_member_request, + NoTenancyForRoomException, + UserExistsException, + UserExistsInRoomException, + EmailTakenException, + LoginTakenException, + MoveInDateInvalidException, + check_similar_user_in_room, + get_name_from_first_last, + confirm_mail_address, + get_user_by_swdd_person_id, + scheduled_membership_start, + send_confirmation_email, + get_user_by_id_or_login, + send_password_reset_mail, + change_password_from_token, +) from pycroft.model import session from pycroft.model.facilities import Room from pycroft.model.finance import Account, Split @@ -143,8 +160,8 @@ class _Entry(t.TypedDict): except ValueError: wifi_password = None - med = membership_end_date(user) - mbd = membership_begin_date(user) + med = scheduled_membership_end(user) + mbd = scheduled_membership_start(user) interface_info = [{ 'id': i.id, From 104219c08058e4ed5ee1c95abc238561c6361e55 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 12:04:24 +0200 Subject: [PATCH 18/32] Make `scheduled_membership_*` implementations more concise --- pycroft/lib/user/info.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/pycroft/lib/user/info.py b/pycroft/lib/user/info.py index 930e1d057..7ed3a1a80 100644 --- a/pycroft/lib/user/info.py +++ b/pycroft/lib/user/info.py @@ -60,12 +60,9 @@ def scheduled_membership_start(user: User) -> date | None: :return: The due date of the task that will begin a membership; None if not existent """ - - begin_task = membership_beginning_task(user) - - end_date = None if begin_task is None else begin_task.due.date() - - return end_date + if (task := membership_beginning_task(user)) is None: + return None + return task.due.date() def scheduled_membership_end(user: User) -> date | None: @@ -73,12 +70,9 @@ def scheduled_membership_end(user: User) -> date | None: :return: The due date of the task that will end the membership; None if not existent """ - - ending_task = membership_ending_task(user) - - end_date = None if ending_task is None else ending_task.due.date() - - return end_date + if (task := membership_ending_task(user)) is None: + return None + return task.due.date() def membership_beginning_task(user: User) -> UserTask: From 19773091ba5ad0618a232807f4d1a4aa7372a83d Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 12:12:14 +0200 Subject: [PATCH 19/32] extract `lib.user.lifecycle` --- pycroft/lib/user/__init__.py | 12 +- pycroft/lib/user/_old.py | 360 +-------------------------- pycroft/lib/user/edit.py | 2 +- pycroft/lib/user/lifecycle.py | 386 +++++++++++++++++++++++++++++ pycroft/lib/user/member_request.py | 14 +- 5 files changed, 406 insertions(+), 368 deletions(-) create mode 100644 pycroft/lib/user/lifecycle.py diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index 24309df57..b08c99dc8 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -1,12 +1,7 @@ from ._old import ( - create_user, - login_available, - move_in, - move, get_blocked_groups, block, unblock, - move_out, send_password_reset_mail, ) from .user_id import ( @@ -32,6 +27,13 @@ membership_ending_task, membership_beginning_task, ) +from .lifecycle import ( + create_user, + login_available, + move_in, + move, + move_out, +) from .passwords import ( maybe_setup_wifi, reset_password, diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index 6c716988f..15ebc13c8 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -10,313 +10,31 @@ :copyright: (c) 2012 by AG DSN. """ import os -import typing as t -from datetime import date -from sqlalchemy import exists, select -from sqlalchemy.orm import Session from pycroft import config -from pycroft.helpers import user as user_helper from pycroft.helpers.i18n import deferred_gettext -from pycroft.helpers.interval import closed, Interval, starting_from -from pycroft.helpers.user import generate_random_str, login_hash +from pycroft.helpers.interval import Interval, starting_from +from pycroft.helpers.user import generate_random_str from pycroft.helpers.utc import DateTimeTz -from pycroft.lib.facilities import get_room -from pycroft.lib.host import migrate_host, setup_ipv4_networking from pycroft.lib.logging import log_user_event from pycroft.lib.mail import ( - UserCreatedTemplate, - UserMovedInTemplate, UserResetPasswordTemplate, ) from pycroft.lib.membership import make_member_of, remove_member_of -from pycroft.lib.net import MacExistsException -from pycroft.lib.task import schedule_user_task from pycroft.model import session -from pycroft.model.address import Address -from pycroft.model.facilities import Room -from pycroft.model.finance import Account -from pycroft.model.host import Host, Interface from pycroft.model.session import with_transaction -from pycroft.model.task import TaskType, UserTask -from pycroft.model.task_serialization import UserMoveParams, UserMoveOutParams, \ - UserMoveInParams from pycroft.model.user import ( User, PropertyGroup, ) -from pycroft.model.unix_account import UnixAccount, UnixTombstone -from .exc import LoginTakenException -from .passwords import generate_wifi_password -from .mail import user_send_mail, send_confirmation_email +from .mail import user_send_mail password_reset_url = os.getenv('PASSWORD_RESET_URL') -def create_user( - name: str, login: str, email: str, birthdate: date, - groups: t.Iterable[PropertyGroup], processor: User | None, address: Address, - passwd_hash: str = None, - send_confirm_mail: bool = False -) -> tuple[User, str]: - """Create a new member - - Create a new user with a generated password, finance- and unix account, and make him member - of the `config.member_group` and `config.network_access_group`. - - :param name: The full name of the user (e.g. Max Mustermann) - :param login: The unix login for the user - :param email: E-Mail address of the user - :param birthdate: Date of birth - :param groups: The initial groups of the new user - :param processor: The processor - :param address: Where the user lives. May or may not come from a room. - :param passwd_hash: Use password hash instead of generating a new password - :param send_confirm_mail: If a confirmation mail should be send to the user - :return: - - :raises LoginTakenException: if the login is used or has been used in the past - """ - - now = session.utcnow() - - if not login_available(login, session.session): - raise LoginTakenException(login) - - plain_password: str | None = user_helper.generate_password(12) - # create a new user - new_user = User( - login=login, - name=name, - email=email, - registered_at=now, - account=Account(name="", type="USER_ASSET"), - password=plain_password, - wifi_password=generate_wifi_password(), - birthdate=birthdate, - address=address - ) - - processor = processor if processor is not None else new_user - - if passwd_hash: - new_user.passwd_hash = passwd_hash - plain_password = None - - account = UnixAccount(home_directory=f"/home/{login}") - new_user.unix_account = account - - with session.session.begin_nested(): - session.session.add(new_user) - session.session.add(account) - new_user.account.name = deferred_gettext("User {id}").format( - id=new_user.id).to_json() - - for group in groups: - make_member_of(new_user, group, processor, closed(now, None)) - - log_user_event(author=processor, - message=deferred_gettext("User created.").to_json(), - user=new_user) - - user_send_mail(new_user, UserCreatedTemplate(), True) - - if email is not None and send_confirm_mail: - send_confirmation_email(new_user) - - return new_user, plain_password - - -def login_available(login: str, session: Session) -> bool: - """Check whether there is a tombstone with the hash of the given login""" - hash = login_hash(login) - stmt = select( - ~exists( - select() - .select_from(UnixTombstone) - .filter(UnixTombstone.login_hash == hash) - .add_columns(1) - ) - ) - return session.scalar(stmt) - - -@with_transaction -def move_in( - user: User, - building_id: int, level: int, room_number: str, - mac: str | None, - processor: User | None = None, - birthdate: date = None, - host_annex: bool = False, - begin_membership: bool = True, - when: DateTimeTz | None = None, -) -> User | UserTask: - """Move in a user in a given room and do some initialization. - - The user is given a new Host with an interface of the given mac, - a finance Account, and is made member of important groups. - Networking is set up. - - Preconditions - ~~~~~~~~~~~~~ - - - User has a unix account. - - :param user: The user to move in - :param building_id: - :param level: - :param room_number: - :param mac: The mac address of the users pc. - :param processor: - :param birthdate: Date of birth - :param host_annex: when true: if MAC already in use, - annex host to new user - :param begin_membership: Starts a membership if true - :param when: The date at which the user should be moved in - - :return: The user object. - """ - - if when and when > session.utcnow(): - task_params = UserMoveInParams( - building_id=building_id, level=level, room_number=room_number, - mac=mac, birthdate=birthdate, - host_annex=host_annex, begin_membership=begin_membership - ) - return schedule_user_task(task_type=TaskType.USER_MOVE_IN, - due=when, - user=user, - parameters=task_params, - processor=processor) - if user.room is not None: - raise ValueError("user is already living in a room.") - - room = get_room(building_id, level, room_number) - - if birthdate: - user.birthdate = birthdate - - if begin_membership: - for group in {config.external_group, config.pre_member_group}: - if user.member_of(group): - remove_member_of( - user, group, processor, starting_from(session.utcnow()) - ) - - for group in {config.member_group, config.network_access_group}: - if not user.member_of(group): - make_member_of(user, group, processor, closed(session.utcnow(), None)) - - if room: - user.room = room - user.address = room.address - - if mac and user.birthdate: - interface_existing = Interface.q.filter_by(mac=mac).first() - - if interface_existing is not None: - if host_annex: - host_existing = interface_existing.host - host_existing.owner_id = user.id - - session.session.add(host_existing) - migrate_host(session.session, host_existing, user.room, processor) - else: - raise MacExistsException - else: - new_host = Host(owner=user, room=room) - session.session.add(new_host) - session.session.add(Interface(mac=mac, host=new_host)) - setup_ipv4_networking(session.session, new_host) - - user_send_mail(user, UserMovedInTemplate(), True) - - msg = deferred_gettext("Moved in: {room}") - - log_user_event(author=processor if processor is not None else user, - message=msg.format(room=room.short_name).to_json(), - user=user) - - return user - - -#TODO ensure serializability -def move( - user: User, - building_id: int, - level: int, - room_number: str, - processor: User, - comment: str | None = None, - when: DateTimeTz | None = None, -) -> User | UserTask: - """Moves the user into another room. - - :param user: The user to be moved. - :param building_id: The id of the building. - :param level: The level of the new room. - :param room_number: The number of the new room. - :param processor: The user initiating this process. Becomes author of the log message. - Not used if execution is deferred! - :param comment: a comment to be included in the log message. - :param when: The date at which the user should be moved - - :return: The user object of the moved user. - """ - - if when and when > session.utcnow(): - task_params = UserMoveParams( - building_id=building_id, level=level, room_number=room_number, - comment=comment - ) - return schedule_user_task(task_type=TaskType.USER_MOVE, - due=when, - user=user, - parameters=task_params, - processor=processor) - - old_room = user.room - had_custom_address = user.has_custom_address - new_room = Room.q.filter_by( - number=room_number, - level=level, - building_id=building_id - ).one() - - assert old_room != new_room,\ - "A User is only allowed to move in a different room!" - - user.room = new_room - if not had_custom_address: - user.address = new_room.address - - args = {'old_room': str(old_room), 'new_room': str(new_room)} - if comment: - message = deferred_gettext("Moved from {old_room} to {new_room}.\n" - "Comment: {comment}") - args.update(comment=comment) - else: - message = deferred_gettext("Moved from {old_room} to {new_room}.") - - log_user_event( - author=processor, - message=message.format(**args).to_json(), - user=user - ) - - for user_host in user.hosts: - if user_host.room == old_room: - migrate_host(session.session, user_host, new_room, processor) - - user_send_mail(user, UserMovedInTemplate(), True) - - return user - - def get_blocked_groups() -> list[PropertyGroup]: return [config.violation_group, config.payment_in_default_group, config.blocked_group] @@ -389,78 +107,6 @@ def unblock(user: User, processor: User, when: DateTimeTz | None = None) -> User return user -@with_transaction -def move_out( - user: User, - comment: str, - processor: User, - when: DateTimeTz, - end_membership: bool = True, -) -> User | UserTask: - """Move out a user and may terminate relevant memberships. - - The user's room is set to ``None`` and all hosts are deleted. - Memberships in :py:obj:`config.member_group` and - :py:obj:`config.member_group` are terminated. A log message is - created including the number of deleted hosts. - - :param user: The user to move out. - :param comment: An optional comment - :param processor: The admin who is going to move out the user. - :param when: The time the user is going to move out. - :param end_membership: Ends membership if true - - :return: The user that moved out. - """ - if when > session.utcnow(): - task_params = UserMoveOutParams(comment=comment, end_membership=end_membership) - return schedule_user_task(task_type=TaskType.USER_MOVE_OUT, - due=when, - user=user, - parameters=task_params, - processor=processor) - - if end_membership: - for group in {config.member_group, - config.external_group, - config.network_access_group}: - if user.member_of(group): - remove_member_of(user, group, processor, starting_from(when)) - - deleted_interfaces = list() - num_hosts = 0 - for num_hosts, h in enumerate(user.hosts, 1): # noqa: B007 - if not h.switch and (h.room == user.room or end_membership): - for interface in h.interfaces: - deleted_interfaces.append(interface.mac) - - session.session.delete(h) - - message = None - - if user.room is not None: - message = "Moved out of {room}: Deleted interfaces {interfaces} of {num_hosts} hosts."\ - .format(room=user.room.short_name, - num_hosts=num_hosts, - interfaces=', '.join(deleted_interfaces)) - user.room = None - elif num_hosts: - message = "Deleted interfaces {interfaces} of {num_hosts} hosts." \ - .format(num_hosts=num_hosts, interfaces=', '.join(deleted_interfaces)) - - if message is not None: - if comment: - message += f"\nComment: {comment}" - - log_user_event( - message=deferred_gettext(message).to_json(), - author=processor, - user=user - ) - - return user - - @with_transaction def send_password_reset_mail(user: User) -> bool: user.password_reset_token = generate_random_str(64) diff --git a/pycroft/lib/user/edit.py b/pycroft/lib/user/edit.py index 6dc4f8e95..f21f56e61 100644 --- a/pycroft/lib/user/edit.py +++ b/pycroft/lib/user/edit.py @@ -7,7 +7,7 @@ from pycroft.model.session import with_transaction from pycroft.model.user import User -from ._old import send_confirmation_email +from .mail import send_confirmation_email from .permission import can_target diff --git a/pycroft/lib/user/lifecycle.py b/pycroft/lib/user/lifecycle.py new file mode 100644 index 000000000..bd094dc25 --- /dev/null +++ b/pycroft/lib/user/lifecycle.py @@ -0,0 +1,386 @@ +# Copyright (c) 2015 The Pycroft Authors. See the AUTHORS file. +# This file is part of the Pycroft project and licensed under the terms of +# the Apache License, Version 2.0. See the LICENSE file for details. +""" +pycroft.lib.user +~~~~~~~~~~~~~~~~ + +This module contains. + +:copyright: (c) 2012 by AG DSN. +""" +import typing as t +from datetime import date +from sqlalchemy import select, exists +from sqlalchemy.orm import Session + + +from pycroft import config +from pycroft.helpers import user as user_helper +from pycroft.helpers.i18n import deferred_gettext +from pycroft.helpers.interval import closed, starting_from +from pycroft.helpers.user import login_hash +from pycroft.helpers.utc import DateTimeTz +from pycroft.lib.facilities import get_room +from pycroft.lib.host import migrate_host, setup_ipv4_networking +from pycroft.lib.logging import log_user_event +from pycroft.lib.mail import ( + UserCreatedTemplate, + UserMovedInTemplate, +) +from pycroft.lib.membership import make_member_of, remove_member_of +from pycroft.lib.net import MacExistsException +from pycroft.lib.task import schedule_user_task +from pycroft.model import session +from pycroft.model.address import Address +from pycroft.model.facilities import Room +from pycroft.model.finance import Account +from pycroft.model.host import Host, Interface +from pycroft.model.session import with_transaction +from pycroft.model.task import TaskType, UserTask +from pycroft.model.task_serialization import UserMoveParams, UserMoveOutParams, UserMoveInParams +from pycroft.model.user import ( + User, + PropertyGroup, +) +from pycroft.model.unix_account import UnixAccount, UnixTombstone + +from .exc import LoginTakenException +from .passwords import generate_wifi_password +from .mail import user_send_mail, send_confirmation_email + + +def create_user( + name: str, + login: str, + email: str, + birthdate: date, + groups: t.Iterable[PropertyGroup], + processor: User | None, + address: Address, + passwd_hash: str = None, + send_confirm_mail: bool = False, +) -> tuple[User, str]: + """Create a new member + + Create a new user with a generated password, finance- and unix account, and make him member + of the `config.member_group` and `config.network_access_group`. + + :param name: The full name of the user (e.g. Max Mustermann) + :param login: The unix login for the user + :param email: E-Mail address of the user + :param birthdate: Date of birth + :param groups: The initial groups of the new user + :param processor: The processor + :param address: Where the user lives. May or may not come from a room. + :param passwd_hash: Use password hash instead of generating a new password + :param send_confirm_mail: If a confirmation mail should be send to the user + :return: + + :raises LoginTakenException: if the login is used or has been used in the past + """ + + now = session.utcnow() + + if not login_available(login, session.session): + raise LoginTakenException(login) + + plain_password: str | None = user_helper.generate_password(12) + # create a new user + new_user = User( + login=login, + name=name, + email=email, + registered_at=now, + account=Account(name="", type="USER_ASSET"), + password=plain_password, + wifi_password=generate_wifi_password(), + birthdate=birthdate, + address=address, + ) + + processor = processor if processor is not None else new_user + + if passwd_hash: + new_user.passwd_hash = passwd_hash + plain_password = None + + account = UnixAccount(home_directory=f"/home/{login}") + new_user.unix_account = account + + with session.session.begin_nested(): + session.session.add(new_user) + session.session.add(account) + new_user.account.name = deferred_gettext("User {id}").format(id=new_user.id).to_json() + + for group in groups: + make_member_of(new_user, group, processor, closed(now, None)) + + log_user_event( + author=processor, message=deferred_gettext("User created.").to_json(), user=new_user + ) + + user_send_mail(new_user, UserCreatedTemplate(), True) + + if email is not None and send_confirm_mail: + send_confirmation_email(new_user) + + return new_user, plain_password + + +def login_available(login: str, session: Session) -> bool: + """Check whether there is a tombstone with the hash of the given login""" + hash = login_hash(login) + stmt = select( + ~exists( + select() + .select_from(UnixTombstone) + .filter(UnixTombstone.login_hash == hash) + .add_columns(1) + ) + ) + return session.scalar(stmt) + + +@with_transaction +def move_in( + user: User, + building_id: int, + level: int, + room_number: str, + mac: str | None, + processor: User | None = None, + birthdate: date = None, + host_annex: bool = False, + begin_membership: bool = True, + when: DateTimeTz | None = None, +) -> User | UserTask: + """Move in a user in a given room and do some initialization. + + The user is given a new Host with an interface of the given mac, + a finance Account, and is made member of important groups. + Networking is set up. + + Preconditions + ~~~~~~~~~~~~~ + + - User has a unix account. + + :param user: The user to move in + :param building_id: + :param level: + :param room_number: + :param mac: The mac address of the users pc. + :param processor: + :param birthdate: Date of birth + :param host_annex: when true: if MAC already in use, + annex host to new user + :param begin_membership: Starts a membership if true + :param when: The date at which the user should be moved in + + :return: The user object. + """ + + if when and when > session.utcnow(): + task_params = UserMoveInParams( + building_id=building_id, + level=level, + room_number=room_number, + mac=mac, + birthdate=birthdate, + host_annex=host_annex, + begin_membership=begin_membership, + ) + return schedule_user_task( + task_type=TaskType.USER_MOVE_IN, + due=when, + user=user, + parameters=task_params, + processor=processor, + ) + if user.room is not None: + raise ValueError("user is already living in a room.") + + room = get_room(building_id, level, room_number) + + if birthdate: + user.birthdate = birthdate + + if begin_membership: + for group in {config.external_group, config.pre_member_group}: + if user.member_of(group): + remove_member_of(user, group, processor, starting_from(session.utcnow())) + + for group in {config.member_group, config.network_access_group}: + if not user.member_of(group): + make_member_of(user, group, processor, closed(session.utcnow(), None)) + + if room: + user.room = room + user.address = room.address + + if mac and user.birthdate: + interface_existing = Interface.q.filter_by(mac=mac).first() + + if interface_existing is not None: + if host_annex: + host_existing = interface_existing.host + host_existing.owner_id = user.id + + session.session.add(host_existing) + migrate_host(session.session, host_existing, user.room, processor) + else: + raise MacExistsException + else: + new_host = Host(owner=user, room=room) + session.session.add(new_host) + session.session.add(Interface(mac=mac, host=new_host)) + setup_ipv4_networking(session.session, new_host) + + user_send_mail(user, UserMovedInTemplate(), True) + + msg = deferred_gettext("Moved in: {room}") + + log_user_event( + author=processor if processor is not None else user, + message=msg.format(room=room.short_name).to_json(), + user=user, + ) + + return user + + +# TODO ensure serializability +def move( + user: User, + building_id: int, + level: int, + room_number: str, + processor: User, + comment: str | None = None, + when: DateTimeTz | None = None, +) -> User | UserTask: + """Moves the user into another room. + + :param user: The user to be moved. + :param building_id: The id of the building. + :param level: The level of the new room. + :param room_number: The number of the new room. + :param processor: The user initiating this process. Becomes author of the log message. + Not used if execution is deferred! + :param comment: a comment to be included in the log message. + :param when: The date at which the user should be moved + + :return: The user object of the moved user. + """ + + if when and when > session.utcnow(): + task_params = UserMoveParams( + building_id=building_id, level=level, room_number=room_number, comment=comment + ) + return schedule_user_task( + task_type=TaskType.USER_MOVE, + due=when, + user=user, + parameters=task_params, + processor=processor, + ) + + old_room = user.room + had_custom_address = user.has_custom_address + new_room = Room.q.filter_by(number=room_number, level=level, building_id=building_id).one() + + assert old_room != new_room, "A User is only allowed to move in a different room!" + + user.room = new_room + if not had_custom_address: + user.address = new_room.address + + args = {"old_room": str(old_room), "new_room": str(new_room)} + if comment: + message = deferred_gettext("Moved from {old_room} to {new_room}.\n" "Comment: {comment}") + args.update(comment=comment) + else: + message = deferred_gettext("Moved from {old_room} to {new_room}.") + + log_user_event(author=processor, message=message.format(**args).to_json(), user=user) + + for user_host in user.hosts: + if user_host.room == old_room: + migrate_host(session.session, user_host, new_room, processor) + + user_send_mail(user, UserMovedInTemplate(), True) + + return user + + +@with_transaction +def move_out( + user: User, + comment: str, + processor: User, + when: DateTimeTz, + end_membership: bool = True, +) -> User | UserTask: + """Move out a user and may terminate relevant memberships. + + The user's room is set to ``None`` and all hosts are deleted. + Memberships in :py:obj:`config.member_group` and + :py:obj:`config.member_group` are terminated. A log message is + created including the number of deleted hosts. + + :param user: The user to move out. + :param comment: An optional comment + :param processor: The admin who is going to move out the user. + :param when: The time the user is going to move out. + :param end_membership: Ends membership if true + + :return: The user that moved out. + """ + if when > session.utcnow(): + task_params = UserMoveOutParams(comment=comment, end_membership=end_membership) + return schedule_user_task( + task_type=TaskType.USER_MOVE_OUT, + due=when, + user=user, + parameters=task_params, + processor=processor, + ) + + if end_membership: + for group in {config.member_group, config.external_group, config.network_access_group}: + if user.member_of(group): + remove_member_of(user, group, processor, starting_from(when)) + + deleted_interfaces = list() + num_hosts = 0 + for num_hosts, h in enumerate(user.hosts, 1): # noqa: B007 + if not h.switch and (h.room == user.room or end_membership): + for interface in h.interfaces: + deleted_interfaces.append(interface.mac) + + session.session.delete(h) + + message = None + + if user.room is not None: + message = ( + "Moved out of {room}: Deleted interfaces {interfaces} of {num_hosts} hosts.".format( + room=user.room.short_name, + num_hosts=num_hosts, + interfaces=", ".join(deleted_interfaces), + ) + ) + user.room = None + elif num_hosts: + message = "Deleted interfaces {interfaces} of {num_hosts} hosts.".format( + num_hosts=num_hosts, interfaces=", ".join(deleted_interfaces) + ) + + if message is not None: + if comment: + message += f"\nComment: {comment}" + + log_user_event(message=deferred_gettext(message).to_json(), author=processor, user=user) + + return user diff --git a/pycroft/lib/user/member_request.py b/pycroft/lib/user/member_request.py index 6662ce871..fb7745425 100644 --- a/pycroft/lib/user/member_request.py +++ b/pycroft/lib/user/member_request.py @@ -24,11 +24,6 @@ ) from ._old import ( - create_user, - login_available, - move_in, - move, - send_confirmation_email, user_send_mail, ) from .edit import ( @@ -45,6 +40,15 @@ NoTenancyForRoomException, MoveInDateInvalidException, ) +from .lifecycle import ( + create_user, + login_available, + move_in, + move, +) +from .mail import ( + send_confirmation_email, +) from .user_id import ( check_user_id, decode_type1_user_id, From a4a906078e5d3151ecd949e7fcb2fc024bd89a78 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 14:30:42 +0200 Subject: [PATCH 20/32] Stricter typing for `lib.user` --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 5137d2a73..0d451bcf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -145,6 +145,7 @@ module = [ "ldap_sync.*", "pycroft.lib", "pycroft.lib.*", + "pycroft.lib.user.*", "pycroft.external_services", "pycroft.external_services.*", "pycroft.helpers", From bbd4ae0bf4071e1dd39e95425aaf8bb25dcddbf8 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 13:00:38 +0200 Subject: [PATCH 21/32] typing: Strict nullability for `lib.user.mail_confirmation` --- pycroft/lib/user/mail_confirmation.py | 2 ++ pyproject.toml | 1 + 2 files changed, 3 insertions(+) diff --git a/pycroft/lib/user/mail_confirmation.py b/pycroft/lib/user/mail_confirmation.py index 852d800b8..f0ae21fc6 100644 --- a/pycroft/lib/user/mail_confirmation.py +++ b/pycroft/lib/user/mail_confirmation.py @@ -30,6 +30,7 @@ def confirm_mail_address( # else: one of {mr, user} is not None if user is None: + assert mr is not None if mr.email_confirmed: raise ValueError("E-Mail already confirmed") @@ -51,6 +52,7 @@ def confirm_mail_address( return "pre_member", reg_result elif mr is None: + assert user is not None user.email_confirmed = True user.email_confirmation_key = None diff --git a/pyproject.toml b/pyproject.toml index 0d451bcf6..dc7b97bbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -171,6 +171,7 @@ disallow_untyped_globals = true [[tool.mypy.overrides]] module = [ "pycroft.lib.finance", + "pycroft.lib.user.mail_confirmation", ] strict_optional = true From 40137d4869a1cc0baeb2b49457e7c6a88fdd2565 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 13:22:31 +0200 Subject: [PATCH 22/32] Remove superfluous args from `check_new_user_data` and fix call The wrong call has been introduced in 6d6cfe3a85fee1d254fad5017daa773eb9f4cf03. --- pycroft/lib/user/member_request.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pycroft/lib/user/member_request.py b/pycroft/lib/user/member_request.py index 2baab4cc5..4f23f613d 100644 --- a/pycroft/lib/user/member_request.py +++ b/pycroft/lib/user/member_request.py @@ -68,8 +68,6 @@ def create_member_request( previous_dorm: str | None, ) -> PreMember: check_new_user_data( - login, - email, name, swdd_person_id, room, @@ -121,14 +119,20 @@ def finish_member_request( prm.move_in_date = utcnow.date() check_new_user_data( - prm.login, - prm.email, prm.name, prm.swdd_person_id, prm.room, prm.move_in_date, ignore_similar_name, ) + assert ( + prm.email is not None + ), f"Called finish_member_request with non-persisted PreMember {prm!r}" + check_new_user_data_unused( + login=prm.login, + email=prm.email, + swdd_person_id=prm.swdd_person_id, + ) user = user_from_pre_member(prm, processor=processor) processor = processor or user @@ -310,8 +314,6 @@ def get_possible_existing_users_for_pre_member(prm: PreMember) -> set[User]: def check_new_user_data( - login: str, - email: str, name: str, swdd_person_id: int | None, room: Room | None, From dbdd8f91ccde319dafee9dc6eab15466d92120b7 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 14:30:25 +0200 Subject: [PATCH 23/32] Strict nullability for `lib.user.member_request` --- pycroft/lib/user/member_request.py | 58 +++++++++++++++--------------- pyproject.toml | 1 + 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/pycroft/lib/user/member_request.py b/pycroft/lib/user/member_request.py index 4f23f613d..47c4689c2 100644 --- a/pycroft/lib/user/member_request.py +++ b/pycroft/lib/user/member_request.py @@ -2,8 +2,10 @@ import typing as t from datetime import timedelta, date from difflib import SequenceMatcher +from itertools import chain -from sqlalchemy import func +from sqlalchemy import func, select +from sqlalchemy.orm import Session from pycroft import config from pycroft.helpers import utc @@ -48,7 +50,6 @@ user_send_mail, ) from .user_id import ( - check_user_id, decode_type1_user_id, decode_type2_user_id, encode_type2_user_id, @@ -110,6 +111,9 @@ def create_member_request( def finish_member_request( prm: PreMember, processor: User | None, ignore_similar_name: bool = False ) -> User: + assert prm.email is not None, f"{prm!r} not persisted" + assert prm.move_in_date is not None, f"{prm!r} not persisted" + if prm.room is None: raise ValueError("Room is None") @@ -125,9 +129,6 @@ def finish_member_request( prm.move_in_date, ignore_similar_name, ) - assert ( - prm.email is not None - ), f"Called finish_member_request with non-persisted PreMember {prm!r}" check_new_user_data_unused( login=prm.login, email=prm.email, @@ -157,7 +158,9 @@ def finish_member_request( return user -def user_from_pre_member(pre_member: PreMember, processor: User) -> User: +def user_from_pre_member(pre_member: PreMember, processor: User | None) -> User: + assert pre_member.email is not None, f"{pre_member!r} not persisted" + assert pre_member.birthdate is not None, f"{pre_member!r} not persisted" user, _ = create_user( pre_member.name, pre_member.login, @@ -233,8 +236,10 @@ def merge_member_request( ) if merge_person_id: + assert prm.swdd_person_id is not None user = edit_person_id(user, prm.swdd_person_id, processor) + assert prm.move_in_date is not None move_in_datetime = utc.with_min_time(prm.move_in_date) if merge_room: @@ -297,16 +302,20 @@ def merge_member_request( def get_possible_existing_users_for_pre_member(prm: PreMember) -> set[User]: + sess: Session = session.session # TODO make parameter + + assert prm.email is not None, f"{prm!r} not persisted!" + user_swdd_person_id = get_user_by_swdd_person_id(prm.swdd_person_id) - user_login = User.q.filter_by(login=prm.login).first() - user_email = User.q.filter(func.lower(User.email) == prm.email.lower()).first() + user_login = sess.scalar(select(User).filter_by(login=prm.login)) + user_email = sess.scalar(select(User).where(func.lower(User.email) == prm.email.lower())) - users_name = User.q.filter_by(name=prm.name).all() + users_name = sess.scalars(select(User).filter_by(name=prm.name)).all() users_similar = get_similar_users_in_room(prm.name, prm.room, 0.5) users = { user - for user in [user_swdd_person_id, user_login, user_email] + users_name + users_similar + for user in chain((user_swdd_person_id, user_login, user_email), users_name, users_similar) if user is not None } @@ -329,7 +338,7 @@ def check_new_user_data( raise MoveInDateInvalidException -def check_new_user_data_unused(login: str, email: str, swdd_person_id: int) -> None: +def check_new_user_data_unused(login: str, email: str, swdd_person_id: int | None) -> None: """Check whether some user data from a member request is already used. :raises UserExistsException: @@ -384,27 +393,20 @@ def get_name_from_first_last(first_name: str, last_name: str) -> str: def get_user_by_id_or_login(ident: str, email: str) -> User | None: - re_uid1 = r"^\d{4,6}-\d{1}$" - re_uid2 = r"^\d{4,6}-\d{2}$" - - user = User.q.filter(func.lower(User.email) == email.lower()) - - if re.match(re_uid1, ident): - if not check_user_id(ident): - return None - user_id, _ = decode_type1_user_id(ident) - user = user.filter_by(id=user_id) - elif re.match(re_uid2, ident): - if not check_user_id(ident): - return None - user_id, _ = decode_type2_user_id(ident) - user = user.filter_by(id=user_id) + stmt = select(User).where(func.lower(User.email) == email.lower()) + + if (d := decode_type1_user_id(ident)) is not None: + user_id, _ = d + stmt = stmt.filter_by(id=user_id) + elif (d := decode_type2_user_id(ident)) is not None: + user_id, _ = d + stmt = stmt.filter_by(id=user_id) elif re.match(BaseUser.login_regex, ident): - user = user.filter_by(login=ident) + stmt = stmt.filter_by(login=ident) else: return None - return t.cast(User | None, user.one_or_none()) + return session.session.scalar(stmt) def find_similar_users(name: str, room: Room, ratio: float) -> t.Iterable[User]: diff --git a/pyproject.toml b/pyproject.toml index dc7b97bbe..b46e8fb6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -172,6 +172,7 @@ disallow_untyped_globals = true module = [ "pycroft.lib.finance", "pycroft.lib.user.mail_confirmation", + "pycroft.lib.user.member_request", ] strict_optional = true From 8c7211942579f73e6781e00cc517b222276675dd Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 17:09:33 +0200 Subject: [PATCH 24/32] Strict nullability for `lib.user.lifecycle` --- pycroft/lib/mail.py | 12 ++++++------ pycroft/lib/task.py | 2 +- pycroft/lib/user/lifecycle.py | 19 +++++++++++-------- pyproject.toml | 2 ++ 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/pycroft/lib/mail.py b/pycroft/lib/mail.py index e23aab544..3b129e185 100644 --- a/pycroft/lib/mail.py +++ b/pycroft/lib/mail.py @@ -68,12 +68,12 @@ def render(self, **kwargs: t.Any) -> tuple[str, str]: def compose_mail(mail: Mail) -> MIMEMultipart: - msg = MIMEMultipart('alternative', _charset='utf-8') - msg['Message-Id'] = make_msgid() - msg['From'] = mail_from - msg['To'] = Header(mail.to_address) - msg['Subject'] = mail.subject - msg['Date'] = formatdate(localtime=True) + msg = MIMEMultipart("alternative", _charset="utf-8") + msg["Message-Id"] = make_msgid() + msg["From"] = mail_from + msg["To"] = str(Header(mail.to_address)) + msg["Subject"] = mail.subject + msg["Date"] = formatdate(localtime=True) msg.attach(MIMEText(mail.body_plain, 'plain', _charset='utf-8')) diff --git a/pycroft/lib/task.py b/pycroft/lib/task.py index 19aade82f..2bc72b744 100644 --- a/pycroft/lib/task.py +++ b/pycroft/lib/task.py @@ -197,7 +197,7 @@ def schedule_user_task( due: DateTimeTz, user: User, parameters: TaskParams, - processor: User, + processor: User | None, ) -> UserTask: if due < session.utcnow(): raise ValueError("the due date must be in the future") diff --git a/pycroft/lib/user/lifecycle.py b/pycroft/lib/user/lifecycle.py index bd094dc25..de26d8a31 100644 --- a/pycroft/lib/user/lifecycle.py +++ b/pycroft/lib/user/lifecycle.py @@ -58,9 +58,9 @@ def create_user( groups: t.Iterable[PropertyGroup], processor: User | None, address: Address, - passwd_hash: str = None, + passwd_hash: str | None = None, send_confirm_mail: bool = False, -) -> tuple[User, str]: +) -> tuple[User, str | None]: """Create a new member Create a new user with a generated password, finance- and unix account, and make him member @@ -139,7 +139,7 @@ def login_available(login: str, session: Session) -> bool: .add_columns(1) ) ) - return session.scalar(stmt) + return session.scalars(stmt).one() @with_transaction @@ -150,7 +150,7 @@ def move_in( room_number: str, mac: str | None, processor: User | None = None, - birthdate: date = None, + birthdate: date | None = None, host_annex: bool = False, begin_membership: bool = True, when: DateTimeTz | None = None, @@ -180,6 +180,7 @@ def move_in( :return: The user object. """ + processor = processor if processor is not None else user if when and when > session.utcnow(): task_params = UserMoveInParams( @@ -237,13 +238,15 @@ def move_in( session.session.add(Interface(mac=mac, host=new_host)) setup_ipv4_networking(session.session, new_host) - user_send_mail(user, UserMovedInTemplate(), True) + msg = deferred_gettext("Moved in: {room}").format(room=room.short_name) + else: + msg = deferred_gettext("Moved in!") - msg = deferred_gettext("Moved in: {room}") + user_send_mail(user, UserMovedInTemplate(), True) log_user_event( - author=processor if processor is not None else user, - message=msg.format(room=room.short_name).to_json(), + author=processor, + message=msg.to_json(), user=user, ) diff --git a/pyproject.toml b/pyproject.toml index b46e8fb6c..b47eb3138 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,7 +170,9 @@ disallow_untyped_globals = true [[tool.mypy.overrides]] module = [ + "pycroft.model.task_serialization", "pycroft.lib.finance", + "pycroft.lib.user.lifecycle", "pycroft.lib.user.mail_confirmation", "pycroft.lib.user.member_request", ] From 5e6504f347418efd2549ba022c6860c05898decd Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 12:46:42 +0200 Subject: [PATCH 25/32] extract `lib.user.blocking` --- pycroft/lib/user/__init__.py | 8 +-- pycroft/lib/user/_old.py | 80 ------------------------------ pycroft/lib/user/blocking.py | 96 ++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 83 deletions(-) create mode 100644 pycroft/lib/user/blocking.py diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index b08c99dc8..f202f58cf 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -1,7 +1,4 @@ from ._old import ( - get_blocked_groups, - block, - unblock, send_password_reset_mail, ) from .user_id import ( @@ -11,6 +8,11 @@ decode_type2_user_id, check_user_id, ) +from .blocking import ( + block, + unblock, + get_blocked_groups, +) from .edit import ( edit_name, edit_email, diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py index 15ebc13c8..2f26df4d5 100644 --- a/pycroft/lib/user/_old.py +++ b/pycroft/lib/user/_old.py @@ -12,21 +12,13 @@ import os -from pycroft import config -from pycroft.helpers.i18n import deferred_gettext -from pycroft.helpers.interval import Interval, starting_from from pycroft.helpers.user import generate_random_str -from pycroft.helpers.utc import DateTimeTz -from pycroft.lib.logging import log_user_event from pycroft.lib.mail import ( UserResetPasswordTemplate, ) -from pycroft.lib.membership import make_member_of, remove_member_of -from pycroft.model import session from pycroft.model.session import with_transaction from pycroft.model.user import ( User, - PropertyGroup, ) from .mail import user_send_mail @@ -35,78 +27,6 @@ password_reset_url = os.getenv('PASSWORD_RESET_URL') -def get_blocked_groups() -> list[PropertyGroup]: - return [config.violation_group, config.payment_in_default_group, - config.blocked_group] - - -@with_transaction -def block( - user: User, - reason: str, - processor: User, - during: Interval[DateTimeTz] = None, - violation: bool = True, -) -> User: - """Suspend a user during a given interval. - - The user is added to violation_group or blocked_group in a given - interval. A reason needs to be provided. - - :param user: The user to be suspended. - :param reason: The reason for suspending. - :param processor: The admin who suspended the user. - :param during: The interval in which the user is - suspended. If None the user will be suspendeded from now on - without an upper bound. - :param violation: If the user should be added to the violation group - - :return: The suspended user. - """ - if during is None: - during = starting_from(session.utcnow()) - - if violation: - make_member_of(user, config.violation_group, processor, during) - else: - make_member_of(user, config.blocked_group, processor, during) - - message = deferred_gettext("Suspended during {during}. Reason: {reason}.") - log_user_event(message=message.format(during=during, reason=reason) - .to_json(), author=processor, user=user) - return user - - -@with_transaction -def unblock(user: User, processor: User, when: DateTimeTz | None = None) -> User: - """Unblocks a user. - - This removes his membership of the violation, blocken and payment_in_default - group. - - Note that for unblocking, no further asynchronous action has to be - triggered, as opposed to e.g. membership termination. - - :param user: The user to be unblocked. - :param processor: The admin who unblocked the user. - :param when: The time of membership termination. Note - that in comparison to :py:func:`suspend`, you don't provide an - _interval_, but a point in time, defaulting to the current - time. Will be converted to ``starting_from(when)``. - - :return: The unblocked user. - """ - if when is None: - when = session.utcnow() - - during = starting_from(when) - for group in get_blocked_groups(): - if user.member_of(group, when=during): - remove_member_of(user=user, group=group, processor=processor, during=during) - - return user - - @with_transaction def send_password_reset_mail(user: User) -> bool: user.password_reset_token = generate_random_str(64) diff --git a/pycroft/lib/user/blocking.py b/pycroft/lib/user/blocking.py new file mode 100644 index 000000000..052db4a90 --- /dev/null +++ b/pycroft/lib/user/blocking.py @@ -0,0 +1,96 @@ +# Copyright (c) 2015 The Pycroft Authors. See the AUTHORS file. +# This file is part of the Pycroft project and licensed under the terms of +# the Apache License, Version 2.0. See the LICENSE file for details. +""" +pycroft.lib.user +~~~~~~~~~~~~~~~~ + +This module contains. + +:copyright: (c) 2012 by AG DSN. +""" + +from pycroft import config +from pycroft.helpers.i18n import deferred_gettext +from pycroft.helpers.interval import Interval, starting_from +from pycroft.helpers.utc import DateTimeTz +from pycroft.lib.logging import log_user_event +from pycroft.lib.membership import make_member_of, remove_member_of +from pycroft.model import session +from pycroft.model.session import with_transaction +from pycroft.model.user import ( + User, + PropertyGroup, +) + + +@with_transaction +def block( + user: User, + reason: str, + processor: User, + during: Interval[DateTimeTz] = None, + violation: bool = True, +) -> User: + """Suspend a user during a given interval. + + The user is added to violation_group or blocked_group in a given + interval. A reason needs to be provided. + + :param user: The user to be suspended. + :param reason: The reason for suspending. + :param processor: The admin who suspended the user. + :param during: The interval in which the user is + suspended. If None the user will be suspendeded from now on + without an upper bound. + :param violation: If the user should be added to the violation group + + :return: The suspended user. + """ + if during is None: + during = starting_from(session.utcnow()) + + if violation: + make_member_of(user, config.violation_group, processor, during) + else: + make_member_of(user, config.blocked_group, processor, during) + + message = deferred_gettext("Suspended during {during}. Reason: {reason}.") + log_user_event( + message=message.format(during=during, reason=reason).to_json(), author=processor, user=user + ) + return user + + +@with_transaction +def unblock(user: User, processor: User, when: DateTimeTz | None = None) -> User: + """Unblocks a user. + + This removes his membership of the violation, blocken and payment_in_default + group. + + Note that for unblocking, no further asynchronous action has to be + triggered, as opposed to e.g. membership termination. + + :param user: The user to be unblocked. + :param processor: The admin who unblocked the user. + :param when: The time of membership termination. Note + that in comparison to :py:func:`suspend`, you don't provide an + _interval_, but a point in time, defaulting to the current + time. Will be converted to ``starting_from(when)``. + + :return: The unblocked user. + """ + if when is None: + when = session.utcnow() + + during = starting_from(when) + for group in get_blocked_groups(): + if user.member_of(group, when=during): + remove_member_of(user=user, group=group, processor=processor, during=during) + + return user + + +def get_blocked_groups() -> list[PropertyGroup]: + return [config.violation_group, config.payment_in_default_group, config.blocked_group] From cd4b43765202393ff6e82c0b8e99b91f600e66d6 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 17:35:24 +0200 Subject: [PATCH 26/32] Strict nullability for rest of `lib.user` --- pycroft/helpers/printing/__init__.py | 2 +- pycroft/lib/mail.py | 12 ++++++------ pycroft/lib/user/blocking.py | 2 +- pycroft/lib/user/edit.py | 2 +- pycroft/lib/user/mail.py | 8 +++++--- pycroft/lib/user/user_sheet.py | 6 +++--- pyproject.toml | 7 +++---- 7 files changed, 20 insertions(+), 19 deletions(-) diff --git a/pycroft/helpers/printing/__init__.py b/pycroft/helpers/printing/__init__.py index eb0d6e50e..3a93e37e5 100644 --- a/pycroft/helpers/printing/__init__.py +++ b/pycroft/helpers/printing/__init__.py @@ -100,7 +100,7 @@ def generate_user_sheet( new_user: bool, wifi: bool, bank_account: BankAccount, - user: User | None = None, + user: User = None, user_id: str | None = None, plain_user_password: str | None = None, generation_purpose: str = "", diff --git a/pycroft/lib/mail.py b/pycroft/lib/mail.py index 3b129e185..e5d62f103 100644 --- a/pycroft/lib/mail.py +++ b/pycroft/lib/mail.py @@ -18,13 +18,13 @@ from pycroft.lib.exc import PycroftLibException -mail_envelope_from = os.environ.get('PYCROFT_MAIL_ENVELOPE_FROM') -mail_from = os.environ.get('PYCROFT_MAIL_FROM') -mail_reply_to = os.environ.get('PYCROFT_MAIL_REPLY_TO') -smtp_host = os.environ.get('PYCROFT_SMTP_HOST') +mail_envelope_from = os.environ["PYCROFT_MAIL_ENVELOPE_FROM"] +mail_from = os.environ["PYCROFT_MAIL_FROM"] +mail_reply_to = os.environ["PYCROFT_MAIL_REPLY_TO"] +smtp_host = os.environ["PYCROFT_SMTP_HOST"] smtp_port = int(os.environ.get('PYCROFT_SMTP_PORT', 465)) -smtp_user = os.environ.get('PYCROFT_SMTP_USER') -smtp_password = os.environ.get('PYCROFT_SMTP_PASSWORD') +smtp_user = os.environ["PYCROFT_SMTP_USER"] +smtp_password = os.environ["PYCROFT_SMTP_PASSWORD"] smtp_ssl = os.environ.get('PYCROFT_SMTP_SSL', 'ssl') template_path_type = os.environ.get('PYCROFT_TEMPLATE_PATH_TYPE', 'filesystem') template_path = os.environ.get('PYCROFT_TEMPLATE_PATH', 'pycroft/templates') diff --git a/pycroft/lib/user/blocking.py b/pycroft/lib/user/blocking.py index 052db4a90..51f6633b7 100644 --- a/pycroft/lib/user/blocking.py +++ b/pycroft/lib/user/blocking.py @@ -29,7 +29,7 @@ def block( user: User, reason: str, processor: User, - during: Interval[DateTimeTz] = None, + during: Interval[DateTimeTz] | None = None, violation: bool = True, ) -> User: """Suspend a user during a given interval. diff --git a/pycroft/lib/user/edit.py b/pycroft/lib/user/edit.py index f21f56e61..b71a63ad2 100644 --- a/pycroft/lib/user/edit.py +++ b/pycroft/lib/user/edit.py @@ -98,7 +98,7 @@ def edit_email( @with_transaction -def edit_birthdate(user: User, birthdate: date, processor: User) -> User: +def edit_birthdate(user: User, birthdate: date | None, processor: User) -> User: """ Changes the birthdate of a user and creates a log entry. diff --git a/pycroft/lib/user/mail.py b/pycroft/lib/user/mail.py index f07dae99d..5e8235183 100644 --- a/pycroft/lib/user/mail.py +++ b/pycroft/lib/user/mail.py @@ -38,7 +38,7 @@ def format_user_mail(user: User, text: str) -> str: id=encode_type2_user_id(user.id), email=user.email if user.email else "-", email_internal=user.email_internal, - room_short=user.room.short_name if user.room_id is not None else "-", + room_short=user.room.short_name if user.room is not None else "-", swdd_person_id=user.swdd_person_id if user.swdd_person_id else "-", ) @@ -48,8 +48,8 @@ def user_send_mails( template: MailTemplate | None = None, soft_fail: bool = False, use_internal: bool = True, - body_plain: str = None, - subject: str = None, + body_plain: str | None = None, + subject: str | None = None, **kwargs: t.Any, ) -> None: """ @@ -94,6 +94,8 @@ def user_send_mails( # No template given, use formatted body_mail instead. if not isinstance(user, User): raise ValueError("Plaintext email not supported for other User types.") + if body_plain is None: + raise ValueError("Must use either template or body_plain") html = None plaintext = format_user_mail(user, body_plain) diff --git a/pycroft/lib/user/user_sheet.py b/pycroft/lib/user/user_sheet.py index af899183d..509744cce 100644 --- a/pycroft/lib/user/user_sheet.py +++ b/pycroft/lib/user/user_sheet.py @@ -13,9 +13,9 @@ def store_user_sheet( new_user: bool, wifi: bool, - user: User | None = None, + user: User, timeout: int = 15, - plain_user_password: str = None, + plain_user_password: str | None = None, generation_purpose: str = "", plain_wifi_password: str = "", ) -> WebStorage: @@ -65,7 +65,7 @@ def get_user_sheet(sheet_id: int) -> bytes | None: def generate_user_sheet( new_user: bool, wifi: bool, - user: User | None = None, + user: User, plain_user_password: str | None = None, generation_purpose: str = "", plain_wifi_password: str = "", diff --git a/pyproject.toml b/pyproject.toml index b47eb3138..09247d653 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -145,7 +145,6 @@ module = [ "ldap_sync.*", "pycroft.lib", "pycroft.lib.*", - "pycroft.lib.user.*", "pycroft.external_services", "pycroft.external_services.*", "pycroft.helpers", @@ -172,9 +171,9 @@ disallow_untyped_globals = true module = [ "pycroft.model.task_serialization", "pycroft.lib.finance", - "pycroft.lib.user.lifecycle", - "pycroft.lib.user.mail_confirmation", - "pycroft.lib.user.member_request", + "pycroft.lib.mail", + "pycroft.lib.user", + "pycroft.lib.user.*", ] strict_optional = true From eaec37cf9dafb70c56651e0a03a8eb8e71277a6b Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Fri, 20 Sep 2024 12:57:06 +0200 Subject: [PATCH 27/32] migrate `send_password_reset_mail` to `.mail` and delete `_old` --- pycroft/lib/user/__init__.py | 4 +-- pycroft/lib/user/_old.py | 45 --------------------------- pycroft/lib/user/mail.py | 23 ++++++++++++++ pycroft/lib/user/mail_confirmation.py | 2 +- pycroft/lib/user/member_request.py | 4 +-- 5 files changed, 26 insertions(+), 52 deletions(-) delete mode 100644 pycroft/lib/user/_old.py diff --git a/pycroft/lib/user/__init__.py b/pycroft/lib/user/__init__.py index f202f58cf..e6899742d 100644 --- a/pycroft/lib/user/__init__.py +++ b/pycroft/lib/user/__init__.py @@ -1,6 +1,3 @@ -from ._old import ( - send_password_reset_mail, -) from .user_id import ( encode_type1_user_id, decode_type1_user_id, @@ -70,6 +67,7 @@ group_send_mail, send_member_request_merged_email, send_confirmation_email, + send_password_reset_mail, ) from .mail_confirmation import ( confirm_mail_address, diff --git a/pycroft/lib/user/_old.py b/pycroft/lib/user/_old.py deleted file mode 100644 index 2f26df4d5..000000000 --- a/pycroft/lib/user/_old.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (c) 2015 The Pycroft Authors. See the AUTHORS file. -# This file is part of the Pycroft project and licensed under the terms of -# the Apache License, Version 2.0. See the LICENSE file for details. -""" -pycroft.lib.user -~~~~~~~~~~~~~~~~ - -This module contains. - -:copyright: (c) 2012 by AG DSN. -""" -import os - - -from pycroft.helpers.user import generate_random_str -from pycroft.lib.mail import ( - UserResetPasswordTemplate, -) -from pycroft.model.session import with_transaction -from pycroft.model.user import ( - User, -) - -from .mail import user_send_mail - - -password_reset_url = os.getenv('PASSWORD_RESET_URL') - - -@with_transaction -def send_password_reset_mail(user: User) -> bool: - user.password_reset_token = generate_random_str(64) - - if not password_reset_url: - raise ValueError("No url specified in PASSWORD_RESET_URL") - - try: - user_send_mail(user, UserResetPasswordTemplate( - password_reset_url=password_reset_url.format(user.password_reset_token)), - use_internal=False) - except ValueError: - user.password_reset_token = None - return False - - return True diff --git a/pycroft/lib/user/mail.py b/pycroft/lib/user/mail.py index d166dc5c9..f07dae99d 100644 --- a/pycroft/lib/user/mail.py +++ b/pycroft/lib/user/mail.py @@ -9,6 +9,7 @@ MailTemplate, Mail, UserConfirmEmailTemplate, + UserResetPasswordTemplate, MemberRequestMergedTemplate, ) from pycroft.model import session @@ -27,6 +28,7 @@ ) mail_confirm_url = os.getenv("MAIL_CONFIRM_URL") +password_reset_url = os.getenv("PASSWORD_RESET_URL") def format_user_mail(user: User, text: str) -> str: @@ -159,3 +161,24 @@ def send_confirmation_email(user: BaseUser) -> None: email_confirm_url=mail_confirm_url.format(user.email_confirmation_key) ), ) + + +def send_password_reset_mail(user: User) -> bool: + user.password_reset_token = generate_random_str(64) + + if not password_reset_url: + raise ValueError("No url specified in PASSWORD_RESET_URL") + + try: + user_send_mail( + user, + UserResetPasswordTemplate( + password_reset_url=password_reset_url.format(user.password_reset_token) + ), + use_internal=False, + ) + except ValueError: + user.password_reset_token = None + return False + + return True diff --git a/pycroft/lib/user/mail_confirmation.py b/pycroft/lib/user/mail_confirmation.py index d5a6017e4..852d800b8 100644 --- a/pycroft/lib/user/mail_confirmation.py +++ b/pycroft/lib/user/mail_confirmation.py @@ -9,7 +9,7 @@ ) from .member_request import finish_member_request -from ._old import user_send_mail +from .mail import user_send_mail @with_transaction diff --git a/pycroft/lib/user/member_request.py b/pycroft/lib/user/member_request.py index fb7745425..2baab4cc5 100644 --- a/pycroft/lib/user/member_request.py +++ b/pycroft/lib/user/member_request.py @@ -23,9 +23,6 @@ RoomHistoryEntry, ) -from ._old import ( - user_send_mail, -) from .edit import ( edit_birthdate, edit_name, @@ -48,6 +45,7 @@ ) from .mail import ( send_confirmation_email, + user_send_mail, ) from .user_id import ( check_user_id, From f826097c6755d6737c77394f914c6c6779c4d25f Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Tue, 24 Sep 2024 19:36:05 +0200 Subject: [PATCH 28/32] Move up `mail_from` config dependency --- pycroft/lib/mail.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pycroft/lib/mail.py b/pycroft/lib/mail.py index e5d62f103..a1270746f 100644 --- a/pycroft/lib/mail.py +++ b/pycroft/lib/mail.py @@ -67,10 +67,10 @@ def render(self, **kwargs: t.Any) -> tuple[str, str]: return plain, html -def compose_mail(mail: Mail) -> MIMEMultipart: +def compose_mail(mail: Mail, from_: str) -> MIMEMultipart: msg = MIMEMultipart("alternative", _charset="utf-8") msg["Message-Id"] = make_msgid() - msg["From"] = mail_from + msg["From"] = from_ msg["To"] = str(Header(mail.to_address)) msg["Subject"] = mail.subject msg["Date"] = formatdate(localtime=True) @@ -159,7 +159,7 @@ def send_mails(mails: list[Mail]) -> tuple[bool, int]: for mail in mails: try: - mime_mail = compose_mail(mail) + mime_mail = compose_mail(mail, from_=mail_from) assert mail_envelope_from is not None smtp.sendmail(from_addr=mail_envelope_from, to_addrs=mail.to_address, msg=mime_mail.as_string()) From f1d10026797398939a2b6d8807de2d25d2d04bb6 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Tue, 24 Sep 2024 19:41:48 +0200 Subject: [PATCH 29/32] Group mail config context in `MailConfig` --- pycroft/lib/mail.py | 117 ++++++++++++++++++++++++++++++++------------ 1 file changed, 87 insertions(+), 30 deletions(-) diff --git a/pycroft/lib/mail.py b/pycroft/lib/mail.py index a1270746f..07bd78f7f 100644 --- a/pycroft/lib/mail.py +++ b/pycroft/lib/mail.py @@ -2,44 +2,38 @@ pycroft.lib.mail ~~~~~~~~~~~~~~~~ """ + +from __future__ import annotations import logging import os import smtplib import ssl import traceback import typing as t -from dataclasses import dataclass +from dataclasses import dataclass, field from email.header import Header from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import make_msgid, formatdate +from functools import lru_cache import jinja2 from pycroft.lib.exc import PycroftLibException -mail_envelope_from = os.environ["PYCROFT_MAIL_ENVELOPE_FROM"] -mail_from = os.environ["PYCROFT_MAIL_FROM"] -mail_reply_to = os.environ["PYCROFT_MAIL_REPLY_TO"] -smtp_host = os.environ["PYCROFT_SMTP_HOST"] -smtp_port = int(os.environ.get('PYCROFT_SMTP_PORT', 465)) -smtp_user = os.environ["PYCROFT_SMTP_USER"] -smtp_password = os.environ["PYCROFT_SMTP_PASSWORD"] -smtp_ssl = os.environ.get('PYCROFT_SMTP_SSL', 'ssl') -template_path_type = os.environ.get('PYCROFT_TEMPLATE_PATH_TYPE', 'filesystem') -template_path = os.environ.get('PYCROFT_TEMPLATE_PATH', 'pycroft/templates') -logger = logging.getLogger('mail') -logger.setLevel(logging.INFO) +# TODO proxy and DI; set at app init +config: MailConfig | None = None -template_loader: jinja2.BaseLoader -if template_path_type == 'filesystem': - template_loader = jinja2.FileSystemLoader(searchpath=f'{template_path}/mail') -else: - template_loader = jinja2.PackageLoader(package_name='pycroft', - package_path=f'{template_path}/mail') -template_env = jinja2.Environment(loader=template_loader) +def set_config(value: MailConfig) -> None: + global config + config = value + return + + +logger = logging.getLogger('mail') +logger.setLevel(logging.INFO) @dataclass @@ -61,13 +55,24 @@ def __init__(self, **kwargs: t.Any) -> None: self.args = kwargs def render(self, **kwargs: t.Any) -> tuple[str, str]: - plain = template_env.get_template(self.template).render(mode='plain', **self.args, **kwargs) - html = template_env.get_template(self.template).render(mode='html', **self.args, **kwargs) + plain = self.jinja_template.render(mode="plain", **self.args, **kwargs) + html = self.jinja_template.render(mode="html", **self.args, **kwargs) return plain, html + @property + def jinja_template(self) -> jinja2.Template: + return _get_template(self.template) + + +@lru_cache(maxsize=None) +def _get_template(template_location: str) -> jinja2.Template: + if config is None: + raise RuntimeError("`mail.config` not set up!") + return config.template_env.get_template(template_location) -def compose_mail(mail: Mail, from_: str) -> MIMEMultipart: + +def compose_mail(mail: Mail, from_: str, default_reply_to: str) -> MIMEMultipart: msg = MIMEMultipart("alternative", _charset="utf-8") msg["Message-Id"] = make_msgid() msg["From"] = from_ @@ -81,7 +86,7 @@ def compose_mail(mail: Mail, from_: str) -> MIMEMultipart: msg.attach(MIMEText(mail.body_html, 'html', _charset='utf-8')) if mail.reply_to is not None or mail.reply_to is not None: - msg['Reply-To'] = mail_reply_to if mail.reply_to is None else mail.reply_to + msg["Reply-To"] = default_reply_to if mail.reply_to is None else mail.reply_to print(msg) @@ -101,11 +106,17 @@ def send_mails(mails: list[Mail]) -> tuple[bool, int]: :returns: Whether the transmission succeeded """ - - if not smtp_host: - logger.critical("No mailserver config available") - - raise RuntimeError + if config is None: + raise RuntimeError("`mail.config` not set up!") + + mail_envelope_from = config.mail_envelope_from + mail_from = config.mail_from + mail_reply_to = config.mail_reply_to + smtp_host = config.smtp_host + smtp_port = config.smtp_port + smtp_user = config.smtp_user + smtp_password = config.smtp_password + smtp_ssl = config.smtp_ssl use_ssl = smtp_ssl == 'ssl' use_starttls = smtp_ssl == 'starttls' @@ -159,7 +170,7 @@ def send_mails(mails: list[Mail]) -> tuple[bool, int]: for mail in mails: try: - mime_mail = compose_mail(mail, from_=mail_from) + mime_mail = compose_mail(mail, from_=mail_from, default_reply_to=mail_reply_to) assert mail_envelope_from is not None smtp.sendmail(from_addr=mail_envelope_from, to_addrs=mail.to_address, msg=mime_mail.as_string()) @@ -248,3 +259,49 @@ def send_template_mails( from pycroft.task import send_mails_async send_mails_async.delay(mails) + + +@dataclass +class MailConfig: + mail_envelope_from: str + mail_from: str + mail_reply_to: str + smtp_host: str + smtp_port: int + smtp_user: str + smtp_password: str + smtp_ssl: str + template_path_type: str + template_path: str + template_env: jinja2.Environment = field(init=False) + + @classmethod + def from_env(cls) -> t.Self: + env = os.environ + return cls( + mail_envelope_from=env["PYCROFT_MAIL_ENVELOPE_FROM"], + mail_from=env["PYCROFT_MAIL_FROM"], + mail_reply_to=env["PYCROFT_MAIL_REPLY_TO"], + smtp_host=env["PYCROFT_SMTP_HOST"], + smtp_port=int(env.get("PYCROFT_SMTP_PORT", 465)), + smtp_user=env["PYCROFT_SMTP_USER"], + smtp_password=env["PYCROFT_SMTP_PASSWORD"], + smtp_ssl=env.get("PYCROFT_SMTP_SSL", "ssl"), + template_path_type=env.get("PYCROFT_TEMPLATE_PATH_TYPE", "filesystem"), + template_path=env.get("PYCROFT_TEMPLATE_PATH", "pycroft/templates"), + ) + + def __post_init__(self) -> None: + template_loader: jinja2.BaseLoader + if self.template_path_type == "filesystem": + template_loader = jinja2.FileSystemLoader(searchpath=f"{self.template_path}/mail") + else: + template_loader = jinja2.PackageLoader( + package_name="pycroft", package_path=f"{self.template_path}/mail" + ) + + self.template_env = jinja2.Environment(loader=template_loader) + + +# TODO do on demand at initialization; replace `config` by proxy to `_config` +set_config(MailConfig.from_env()) From 11e1176a498ad1f0015ff43c30f8f8b5d1d61708 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Sat, 28 Sep 2024 10:34:14 +0200 Subject: [PATCH 30/32] lib: use configvar for mail config --- pycroft/lib/mail.py | 55 +++++++++++++++++++++++-------------------- tests/lib/conftest.py | 17 +++++++++++++ 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/pycroft/lib/mail.py b/pycroft/lib/mail.py index 07bd78f7f..533222c18 100644 --- a/pycroft/lib/mail.py +++ b/pycroft/lib/mail.py @@ -10,7 +10,8 @@ import ssl import traceback import typing as t -from dataclasses import dataclass, field +from contextvars import ContextVar +from dataclasses import dataclass, field, InitVar from email.header import Header from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -18,19 +19,14 @@ from functools import lru_cache import jinja2 +from werkzeug.local import LocalProxy from pycroft.lib.exc import PycroftLibException # TODO proxy and DI; set at app init -config: MailConfig | None = None - - -def set_config(value: MailConfig) -> None: - global config - config = value - return - +_config_var: ContextVar[MailConfig] = ContextVar("config") +config: MailConfig = LocalProxy(_config_var) # type: ignore[assignment] logger = logging.getLogger('mail') logger.setLevel(logging.INFO) @@ -105,6 +101,7 @@ def send_mails(mails: list[Mail]) -> tuple[bool, int]: :param mails: A list of mails :returns: Whether the transmission succeeded + :context: config """ if config is None: raise RuntimeError("`mail.config` not set up!") @@ -267,41 +264,47 @@ class MailConfig: mail_from: str mail_reply_to: str smtp_host: str - smtp_port: int smtp_user: str smtp_password: str - smtp_ssl: str - template_path_type: str - template_path: str + smtp_port: int = field(default=465) + smtp_ssl: str = field(default="ssl") + + template_path_type: InitVar[str | None] = None + template_path: InitVar[str | None] = None template_env: jinja2.Environment = field(init=False) @classmethod def from_env(cls) -> t.Self: env = os.environ - return cls( + config = cls( mail_envelope_from=env["PYCROFT_MAIL_ENVELOPE_FROM"], mail_from=env["PYCROFT_MAIL_FROM"], mail_reply_to=env["PYCROFT_MAIL_REPLY_TO"], smtp_host=env["PYCROFT_SMTP_HOST"], - smtp_port=int(env.get("PYCROFT_SMTP_PORT", 465)), smtp_user=env["PYCROFT_SMTP_USER"], smtp_password=env["PYCROFT_SMTP_PASSWORD"], - smtp_ssl=env.get("PYCROFT_SMTP_SSL", "ssl"), - template_path_type=env.get("PYCROFT_TEMPLATE_PATH_TYPE", "filesystem"), - template_path=env.get("PYCROFT_TEMPLATE_PATH", "pycroft/templates"), + template_path_type=env.get("PYCROFT_TEMPLATE_PATH_TYPE"), + template_path=env.get("PYCROFT_TEMPLATE_PATH"), ) + if (smtp_port := env.get("PYCROFT_SMTP_PORT")) is not None: + config.smtp_port = int(smtp_port) + if (smtp_ssl := env.get("PYCROFT_SMTP_SSL")) is not None: + config.smtp_ssl = smtp_ssl - def __post_init__(self) -> None: + return config + + def __post_init__(self, template_path_type: str | None, template_path: str | None) -> None: template_loader: jinja2.BaseLoader - if self.template_path_type == "filesystem": - template_loader = jinja2.FileSystemLoader(searchpath=f"{self.template_path}/mail") + if template_path_type is None: + template_path_type = "filesystem" + if template_path is None: + template_path = "pycroft/templates" + + if template_path_type == "filesystem": + template_loader = jinja2.FileSystemLoader(searchpath=f"{template_path}/mail") else: template_loader = jinja2.PackageLoader( - package_name="pycroft", package_path=f"{self.template_path}/mail" + package_name="pycroft", package_path=f"{template_path}/mail" ) self.template_env = jinja2.Environment(loader=template_loader) - - -# TODO do on demand at initialization; replace `config` by proxy to `_config` -set_config(MailConfig.from_env()) diff --git a/tests/lib/conftest.py b/tests/lib/conftest.py index da0db345a..a7ce75d7a 100644 --- a/tests/lib/conftest.py +++ b/tests/lib/conftest.py @@ -6,6 +6,7 @@ from sqlalchemy.future import select from pycroft.model import _all as m +from pycroft.lib.mail import MailConfig, _config_var from tests import factories @@ -22,3 +23,19 @@ def processor(module_session) -> m.User: @pytest.fixture(scope="module") def config(module_session) -> m.Config: return factories.ConfigFactory.create() + + +@pytest.fixture(scope="module", autouse=True) +def with_mail_config(): + token = _config_var.set( + MailConfig( + mail_envelope_from="noreply@agdsn.de", + mail_from="noreply@agdsn.de", + mail_reply_to="support@agdsn.de", + smtp_host="agdsn.de", + smtp_user="pycroft", + smtp_password="password", + ) + ) + yield + _config_var.reset(token) From fab32b3bfd8b2f8c51da5d2b06a4610745746f41 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Sat, 28 Sep 2024 10:55:17 +0200 Subject: [PATCH 31/32] Fix nullability requirement of `reply_to` This fixes a buggy if-check introduced in 02eb901744f5ffe42fe468d460cc291d06ed3a68. It clarifies the nullability intent of the environment variable, which e.g. is not set in the dev setup (but might be in production). --- pycroft/lib/mail.py | 10 +++++----- scripts/server_run.py | 3 +++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pycroft/lib/mail.py b/pycroft/lib/mail.py index 533222c18..b65de2915 100644 --- a/pycroft/lib/mail.py +++ b/pycroft/lib/mail.py @@ -68,7 +68,7 @@ def _get_template(template_location: str) -> jinja2.Template: return config.template_env.get_template(template_location) -def compose_mail(mail: Mail, from_: str, default_reply_to: str) -> MIMEMultipart: +def compose_mail(mail: Mail, from_: str, default_reply_to: str | None) -> MIMEMultipart: msg = MIMEMultipart("alternative", _charset="utf-8") msg["Message-Id"] = make_msgid() msg["From"] = from_ @@ -81,8 +81,8 @@ def compose_mail(mail: Mail, from_: str, default_reply_to: str) -> MIMEMultipart if mail.body_html is not None: msg.attach(MIMEText(mail.body_html, 'html', _charset='utf-8')) - if mail.reply_to is not None or mail.reply_to is not None: - msg["Reply-To"] = default_reply_to if mail.reply_to is None else mail.reply_to + if reply_to := mail.reply_to or default_reply_to: + msg["Reply-To"] = reply_to print(msg) @@ -262,7 +262,7 @@ def send_template_mails( class MailConfig: mail_envelope_from: str mail_from: str - mail_reply_to: str + mail_reply_to: str | None smtp_host: str smtp_user: str smtp_password: str @@ -279,7 +279,7 @@ def from_env(cls) -> t.Self: config = cls( mail_envelope_from=env["PYCROFT_MAIL_ENVELOPE_FROM"], mail_from=env["PYCROFT_MAIL_FROM"], - mail_reply_to=env["PYCROFT_MAIL_REPLY_TO"], + mail_reply_to=env.get("PYCROFT_MAIL_REPLY_TO"), smtp_host=env["PYCROFT_SMTP_HOST"], smtp_user=env["PYCROFT_SMTP_USER"], smtp_password=env["PYCROFT_SMTP_PASSWORD"], diff --git a/scripts/server_run.py b/scripts/server_run.py index baa3bc9fc..1726791fe 100755 --- a/scripts/server_run.py +++ b/scripts/server_run.py @@ -19,6 +19,7 @@ import pycroft import web from pycroft.helpers.i18n import set_translation_lookup, get_locale +from pycroft.lib.mail import _config_var, MailConfig from pycroft.model.session import set_scoped_session from scripts.connection import get_connection_string from pycroft.model.alembic import determine_schema_state @@ -54,6 +55,8 @@ def prepare_server(echo=False, ensure_schema=False) -> PycroftFlask: ) ) _setup_translations() + _config_var.set(MailConfig.from_env()) + if app.config.get("PROFILE", False): app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[30]) return app From e1a0e13c55dfe44904feda5ec93317c823d5c945 Mon Sep 17 00:00:00 2001 From: Lukas Juhrich Date: Sat, 28 Sep 2024 10:59:42 +0200 Subject: [PATCH 32/32] lib.mail: Move MIMEText transformation to `Mail` class --- pycroft/lib/mail.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/pycroft/lib/mail.py b/pycroft/lib/mail.py index b65de2915..dfe029036 100644 --- a/pycroft/lib/mail.py +++ b/pycroft/lib/mail.py @@ -41,6 +41,16 @@ class Mail: body_html: str | None = None reply_to: str | None = None + @property + def body_plain_mime(self) -> MIMEText: + return MIMEText(self.body_plain, "plain", _charset="utf-8") + + @property + def body_html_mime(self) -> MIMEText | None: + if not self.body_html: + return None + return MIMEText(self.body_html, "html", _charset="utf-8") + class MailTemplate: template: str @@ -75,12 +85,9 @@ def compose_mail(mail: Mail, from_: str, default_reply_to: str | None) -> MIMEMu msg["To"] = str(Header(mail.to_address)) msg["Subject"] = mail.subject msg["Date"] = formatdate(localtime=True) - - msg.attach(MIMEText(mail.body_plain, 'plain', _charset='utf-8')) - - if mail.body_html is not None: - msg.attach(MIMEText(mail.body_html, 'html', _charset='utf-8')) - + msg.attach(mail.body_plain_mime) + if (html := mail.body_html_mime) is not None: + msg.attach(html) if reply_to := mail.reply_to or default_reply_to: msg["Reply-To"] = reply_to