diff --git a/pycroft/lib/membership.py b/pycroft/lib/membership.py index 47402dd63..908c85e60 100644 --- a/pycroft/lib/membership.py +++ b/pycroft/lib/membership.py @@ -9,12 +9,15 @@ management. """ +from __future__ import annotations import typing as t -from sqlalchemy import and_, func, distinct, Result +from sqlalchemy import and_, func, distinct, Result, nulls_last from sqlalchemy.future import select from sqlalchemy.orm import aliased +from sqlalchemy.sql import Select, ClauseElement +from pycroft import Config from pycroft.helpers.i18n import deferred_gettext from pycroft.helpers.interval import UnboundedInterval, IntervalSet, Interval from pycroft.helpers.utc import DateTimeTz @@ -203,3 +206,34 @@ def user_memberships_query( .group_by(Membership.id) ) return session.session.execute(memberships) + + +def select_user_and_last_mem() -> Select: # Select[Tuple[int, int, str]] + """Select users with their last membership of a user in the ``member`` group. + + :returns: a select statement with columns ``user_id``, ``mem_id``, ``mem_end``. + """ + mem_ends_at = func.upper(Membership.active_during) + # see FunctionElement.over for documentation on `partition_by`, `order_by` + # ideally, sqlalchemy would support named windows; + # instead, we have to re-use the arguments. + window_args: dict[str, ClauseElement | t.Sequence[ClauseElement | str] | None] = { + "partition_by": User.id, + "order_by": nulls_last(mem_ends_at), + } + return ( + select() + .select_from(User) + .distinct() + .join(Membership) + .join(Config, Config.member_group_id == Membership.group_id) + .add_columns( + User.id.label("user_id"), + func.last_value(Membership.id) + .over(**window_args, rows=(None, None)) # type: ignore[no-untyped-call] + .label("mem_id"), + func.last_value(mem_ends_at) + .over(**window_args, rows=(None, None)) # type: ignore[no-untyped-call] + .label("mem_end"), + ) + ) # mypy: ignore[no-untyped-call] diff --git a/pycroft/lib/user_deletion.py b/pycroft/lib/user_deletion.py index bf831f9dd..c976b1109 100644 --- a/pycroft/lib/user_deletion.py +++ b/pycroft/lib/user_deletion.py @@ -5,17 +5,17 @@ This module contains methods concerning user archival and deletion. """ from __future__ import annotations -from datetime import timedelta, datetime -from typing import Protocol, Sequence +from datetime import datetime +from typing import Protocol, Sequence, cast -from sqlalchemy import func, nulls_last, and_, not_ +from sqlalchemy import func, and_, not_ from sqlalchemy.future import select from sqlalchemy.orm import joinedload, Session -from sqlalchemy.sql.functions import current_timestamp +from sqlalchemy.sql import Select -from pycroft import Config from pycroft.model.property import CurrentProperty -from pycroft.model.user import User, Membership +from pycroft.model.user import User +from pycroft.lib.membership import select_user_and_last_mem class ArchivableMemberInfo(Protocol): @@ -24,43 +24,13 @@ class ArchivableMemberInfo(Protocol): mem_end: datetime -def get_archivable_members(session: Session) -> Sequence[ArchivableMemberInfo]: - """Return all the users that qualify for being archived right now. - - Selected are those users - - whose last membership in the member_group ended two weeks in the past, - - excluding users who currently have the `do-not-archive` property. - """ - # see FunctionElement.over - mem_ends_at = func.upper(Membership.active_during) - window_args = { - 'partition_by': User.id, - 'order_by': nulls_last(mem_ends_at), - } - # mypy: ignore[no-untyped-call] - last_mem = ( - select( - User.id.label('user_id'), - func.last_value(Membership.id) - .over(**window_args, rows=(None, None)) # type: ignore[no-untyped-call] - .label("mem_id"), - func.last_value(mem_ends_at) - .over(**window_args, rows=(None, None)) # type: ignore[no-untyped-call] - .label("mem_end"), - ) - .select_from(User) - .distinct() - .join(Membership) - .join(Config, Config.member_group_id == Membership.group_id) - ).cte( - "last_mem" - ) # mypy: ignore[no-untyped-call] - stmt = ( - select( - User, - last_mem.c.mem_id, - last_mem.c.mem_end, - ) +def select_archivable_members( + current_year: int, +) -> Select: # Select[Tuple[User, int, datetime]] + # last_mem: (user_id, mem_id, mem_end) + last_mem = select_user_and_last_mem().cte("last_mem") + return ( + select() .select_from(last_mem) # Join the granted `do-not-archive` property, if existent .join(CurrentProperty, @@ -71,20 +41,59 @@ def get_archivable_members(session: Session) -> Sequence[ArchivableMemberInfo]: # …and use that to filter out the `do-not-archive` occurrences. .filter(CurrentProperty.property_name.is_(None)) .join(User, User.id == last_mem.c.user_id) - .filter(last_mem.c.mem_end < current_timestamp() - timedelta(days=14)) # type: ignore[no-untyped-call] + .filter(func.extract("year", last_mem.c.mem_end) + 2 <= current_year) # type: ignore[no-untyped-call] .order_by(last_mem.c.mem_end) - .options(joinedload(User.hosts), # joinedload(User.current_memberships), - joinedload(User.account, innerjoin=True), joinedload(User.room), - joinedload(User.current_properties_maybe_denied)) + .add_columns( + User, + last_mem.c.mem_id, + last_mem.c.mem_end, + ) ) - return session.execute(stmt).unique().all() +def get_archivable_members( + session: Session, + current_year: int | None = None, +) -> Sequence[ArchivableMemberInfo]: + """Return all the users that qualify for being archived right now. -def get_invalidated_archive_memberships() -> list[Membership]: - """Get all memberships in `to_be_archived` of users who have an active `do-not-archive` property. + Selected are those users + - whose last membership in the member_group ended two weeks in the past, + - excluding users who currently have the `do-not-archive` property. + + We joined load the following information: + - hosts + - account + - room + - current_properties_maybe_denied - This can happen if archivability is detected, and later the user becomes a member again, - or if for some reason the user shall not be archived. + :param session: + :param current_year: dependency injection of the current year. + defaults to the current year. """ + return cast( + list[ArchivableMemberInfo], + session.execute( + select_archivable_members( + # I know we're sloppy with time zones, + # but ±2h around new year's eve don't matter. + current_year=current_year + or datetime.now().year + ).options( + joinedload(User.hosts), + # joinedload(User.current_memberships), + joinedload(User.account, innerjoin=True), + joinedload(User.room), + joinedload(User.current_properties_maybe_denied), + ) + ).unique().all(), + ) + + +def archive_users(session: Session, user_ids: Sequence[int]) -> None: + # todo remove hosts + # todo remove tasks + # todo remove log entries + # todo insert these users into an archival log + # todo add membership in archival group pass diff --git a/tests/factories/log.py b/tests/factories/log.py index 2e2aac955..4b98461ad 100644 --- a/tests/factories/log.py +++ b/tests/factories/log.py @@ -13,6 +13,8 @@ class Meta: message = factory.Faker('paragraph') author = factory.SubFactory(UserFactory) user = factory.SubFactory(UserFactory) + created_at = None + class RoomLogEntryFactory(BaseFactory): class Meta: diff --git a/tests/factories/task.py b/tests/factories/task.py index 7b4ced796..a59e0cbe2 100644 --- a/tests/factories/task.py +++ b/tests/factories/task.py @@ -1,6 +1,6 @@ import datetime -import factory +import factory.fuzzy from pycroft.model.task import UserTask, TaskType, Task, TaskStatus from tests.factories.base import BaseFactory @@ -14,7 +14,7 @@ class TaskFactory(BaseFactory): class Meta: model = Task - type: TaskType = None + type: TaskType = factory.fuzzy.FuzzyChoice(TaskType) due = None parameters_json = None created = None @@ -34,3 +34,8 @@ class Meta: model = UserTask user = factory.SubFactory('tests.factories.UserFactory') + + class Params: + self_created = factory.Trait( + creator=factory.SelfAttribute('user') + ) diff --git a/tests/factories/user.py b/tests/factories/user.py index bdfffcc76..4a15b0750 100644 --- a/tests/factories/user.py +++ b/tests/factories/user.py @@ -32,6 +32,7 @@ class Meta: room = factory.SubFactory(RoomFactory) address = factory.SelfAttribute('room.address') unix_account = None + swdd_person_id = None @classmethod def _create(cls, model_class, *args, **kwargs): @@ -64,6 +65,20 @@ class Params: room=None, address=factory.SubFactory('tests.factories.address.AddressFactory'), ) + with_creation_log_entry = factory.Trait( + log_entry=factory.RelatedFactory( + 'tests.factories.log.UserLogEntryFactory', 'user', + created_at=factory.SelfAttribute('..registered_at'), + message="User created", + ) + ) + with_random_task = factory.Trait( + task=factory.RelatedFactory( + 'tests.factories.task.UserTaskFactory', 'user', + self_created=True, + due_yesterday=True, + ) + ) @factory.post_generation def room_history_entries(self, create, extracted, **kwargs): diff --git a/tests/lib/user/test_deletion.py b/tests/lib/user/test_deletion.py new file mode 100644 index 000000000..413f0703c --- /dev/null +++ b/tests/lib/user/test_deletion.py @@ -0,0 +1,162 @@ +# Copyright (c) 2021. 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 +from datetime import datetime, date +from typing import Sequence + +import pytest + +from pycroft.helpers.interval import closed, closedopen +from pycroft.helpers.utc import with_min_time +from pycroft.lib.user_deletion import ( + select_archivable_members, + archive_users, + ArchivableMemberInfo, +) +from pycroft.model.user import User +from tests.factories import UserFactory, ConfigFactory, MembershipFactory, \ + PropertyGroupFactory, \ + HostFactory + + +def get_archivable_members(session, current_year=2022): + """Like `get_archivable_members`, just without all the joinedloads.""" + return session.execute(select_archivable_members(current_year)).all() + + +@pytest.fixture(scope='module') +def config(module_session): + return ConfigFactory() + + +def test_no_archivable_users(session): + assert get_archivable_members(session) == [] + + +def test_users_without_membership_not_in_list(session): + UserFactory.create_batch(5) + assert get_archivable_members(session) == [] + + +def filter_members(members, user): + return [(u, id, end) for u, id, end in members if u == user] + + +def assert_member_present( + members: Sequence[ArchivableMemberInfo], + expected_user: User, + expected_end_date: date, +): + relevant_members = filter_members(members, expected_user) + assert len(relevant_members) == 1 + [(_, mem_id, mem_end)] = relevant_members + assert mem_id is not None + assert mem_end.date() == expected_end_date + + +def assert_member_absent( + members: Sequence[ArchivableMemberInfo], + expected_absent_user: User, +): + assert not filter_members(members, expected_absent_user) + + +class TestArchivableUserSelection: + @pytest.fixture( + scope="class", + params=[date(2020, 3, 1), date(2020, 1, 1), date(2020, 12, 15)], + ) + def end_date(self, request): + return request.param + + @pytest.fixture(scope='class') + def do_not_archive_group(self, class_session): + return PropertyGroupFactory(granted={'do-not-archive'}) + + @pytest.fixture(scope="class") + def old_user(self, class_session, config, do_not_archive_group, end_date) -> User: + user = UserFactory.create( + registered_at=datetime(2000, 1, 1), + with_membership=True, + membership__active_during=closed( + with_min_time(date(2020, 1, 1)), with_min_time(end_date) + ), + membership__group=config.member_group, + ) + MembershipFactory.create( + user=user, group=do_not_archive_group, + active_during=closed(datetime(2000, 1, 1), datetime(2010, 1, 1)) + ) + return user + + @pytest.fixture(scope='class', autouse=True) + def other_users(self, class_session): + return UserFactory.create_batch(5) + + @pytest.fixture + def do_not_archive_membership(self, session, old_user, do_not_archive_group): + return MembershipFactory( + user=old_user, group=do_not_archive_group, + active_during=closedopen(datetime(2020, 1, 1), None), + ) + + @pytest.mark.parametrize("year", [2022, 2023]) + def test_old_users_in_deletion_list_after(self, session, old_user, year, end_date): + members = get_archivable_members(session, current_year=year) + assert_member_present(members, old_user, end_date) + + @pytest.mark.parametrize("year", [2019, 2020, 2021]) + def test_old_user_not_in_list_before(self, session, old_user, year): + assert_member_absent( + get_archivable_members(session, current_year=year), old_user + ) + + @pytest.mark.parametrize("year", list(range(2019, 2023))) + def test_user_with_do_not_archive_not_in_list( + self, session, old_user, do_not_archive_membership, year + ): + assert_member_absent( + get_archivable_members(session, current_year=year), old_user + ) + + @pytest.mark.parametrize('num_hosts', [0, 2]) + def test_user_with_host_in_list(self, session, old_user, num_hosts, end_date): + if num_hosts: + HostFactory.create_batch(num_hosts, owner=old_user) + members = get_archivable_members(session) + assert_member_present(members, old_user, end_date) + + def test_user_with_room_in_list(self, session, old_user, end_date): + with session.begin_nested(): + old_user.room = None + session.add(old_user) + members = get_archivable_members(session) + assert_member_present(members, old_user, end_date) + + +class TestUserArchival: + @pytest.fixture(scope='class') + def archivable_users(self, class_session, config): + return UserFactory.create_batch( + 3, + with_membership=True, + membership__active_during=closed(datetime(2020, 1, 1), datetime(2020, 3, 1)), + membership__group=config.member_group, + with_host=True, patched=True, + with_creation_log_entry=True, + with_random_task=True, + ) + + @pytest.fixture(scope='class') + def archived_users(self, class_session, archivable_users): + archive_users(class_session, [u.id for u in archivable_users]) + return archivable_users + + @pytest.mark.parametrize('index', [0, 1, 2]) + def test_user_archival(self, archived_users, index): + user = archived_users[index] + assert user.tasks == [], "archival did not delete tasks" + assert [le for le in user.log_entries + if le.created_at == user.registered_at] == [], "archival did not delete logs" + assert user.hosts == [], "archival did not delete hosts" + assert 'archived' in user.current_properties