Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Archival #538

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from
36 changes: 35 additions & 1 deletion pycroft/lib/membership.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -203,3 +206,34 @@
.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,

Check failure on line 221 in pycroft/lib/membership.py

View workflow job for this annotation

GitHub Actions / python-lint

Error

Dict entry 0 has incompatible type "str": "InstrumentedAttribute[int]"; expected "str": "ClauseElement | Sequence[ClauseElement | str]" [dict-item]
"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]
113 changes: 61 additions & 52 deletions pycroft/lib/user_deletion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -24,43 +24,13 @@
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,
Expand All @@ -71,20 +41,59 @@
# …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]

Check failure on line 44 in pycroft/lib/user_deletion.py

View workflow job for this annotation

GitHub Actions / python-lint

Error

Unused "type: ignore" comment [unused-ignore]
.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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks do me like we are now testing for >= 2 years and not weeks.

- 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
2 changes: 2 additions & 0 deletions tests/factories/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 7 additions & 2 deletions tests/factories/task.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -34,3 +34,8 @@ class Meta:
model = UserTask

user = factory.SubFactory('tests.factories.UserFactory')

class Params:
self_created = factory.Trait(
creator=factory.SelfAttribute('user')
)
15 changes: 15 additions & 0 deletions tests/factories/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading