Skip to content

Commit

Permalink
(PC-31164)[BO] feat: automate user anonymization when they pass 21
Browse files Browse the repository at this point in the history
  • Loading branch information
Meewan committed Aug 27, 2024
1 parent 580fe3c commit e1e569d
Show file tree
Hide file tree
Showing 4 changed files with 90 additions and 27 deletions.
2 changes: 2 additions & 0 deletions api/src/pcapi/core/users/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ def suspend_account(
_USER_REQUESTED_REASONS = {
constants.SuspensionReason.UPON_USER_REQUEST,
constants.SuspensionReason.SUSPICIOUS_LOGIN_REPORTED_BY_USER,
constants.SuspensionReason.WAITING_FOR_ANONYMIZATION,
}

_AUTO_REQUESTED_REASONS = {
Expand All @@ -490,6 +491,7 @@ def suspend_account(
constants.SuspensionReason.FRAUD_HACK,
constants.SuspensionReason.SUSPICIOUS_LOGIN_REPORTED_BY_USER,
constants.SuspensionReason.UPON_USER_REQUEST,
constants.SuspensionReason.WAITING_FOR_ANONYMIZATION,
}


Expand Down
2 changes: 2 additions & 0 deletions api/src/pcapi/core/users/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def __str__(self) -> str:
SUSPICIOUS_LOGIN_REPORTED_BY_USER = "suspicious login reported by user"
SUSPENSION_FOR_INVESTIGATION_TEMP = "temporary suspension for investigation"
UPON_USER_REQUEST = "upon user request"
WAITING_FOR_ANONYMIZATION = "waiting for anonymization"


PRO_SUSPENSION_REASON_CHOICES = {
Expand All @@ -87,6 +88,7 @@ def __str__(self) -> str:
SuspensionReason.FRAUD_USURPATION: "Fraude usurpation",
SuspensionReason.SUSPICIOUS_LOGIN_REPORTED_BY_USER: "Connexion suspicieuse signalée par l'utilisateur",
SuspensionReason.UPON_USER_REQUEST: "Demande de l'utilisateur",
SuspensionReason.WAITING_FOR_ANONYMIZATION: "En attente d'anonymisation",
}

SUSPENSION_REASON_CHOICES = PRO_SUSPENSION_REASON_CHOICES | PUBLIC_SUSPENSION_REASON_CHOICES
Expand Down
75 changes: 54 additions & 21 deletions api/src/pcapi/routes/backoffice/accounts/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from pcapi.models.beneficiary_import_status import BeneficiaryImportStatus
from pcapi.models.feature import FeatureToggle
from pcapi.repository import atomic
from pcapi.repository import mark_transaction_as_invalid
from pcapi.routes.backoffice import search_utils
from pcapi.routes.backoffice import utils
from pcapi.routes.backoffice.forms import empty as empty_forms
Expand Down Expand Up @@ -111,12 +112,7 @@ def _apply_search_filters(query: BaseQuery, search_filters: list[str]) -> BaseQu


def is_beneficiary_anonymizable(user: users_models.User) -> bool:
# Check if the user is admin, pro or anonymised
beneficiary_roles = {
users_models.UserRole.BENEFICIARY,
users_models.UserRole.UNDERAGE_BENEFICIARY,
}
if not beneficiary_roles.issuperset(user.roles):
if not _is_only_beneficiary(user):
return False

# Check if the user never had credits.
Expand All @@ -132,6 +128,15 @@ def is_beneficiary_anonymizable(user: users_models.User) -> bool:
return False


def _is_only_beneficiary(user: users_models.User) -> bool:
# Check if the user is admin, pro or anonymised
beneficiary_roles = {
users_models.UserRole.BENEFICIARY,
users_models.UserRole.UNDERAGE_BENEFICIARY,
}
return beneficiary_roles.issuperset(user.roles)


@public_accounts_blueprint.route("<int:user_id>/anonymize", methods=["POST"])
@atomic()
@utils.permission_required(perm_models.Permissions.ANONYMIZE_PUBLIC_ACCOUNT)
Expand All @@ -148,9 +153,6 @@ def anonymize_public_account(user_id: int) -> utils.BackofficeResponse:
if not user:
raise NotFound()

if not is_beneficiary_anonymizable(user):
raise BadRequest()

form = empty_forms.EmptyForm()
if not form.validate():
flash(utils.build_form_error_msg(form), "warning")
Expand All @@ -160,15 +162,47 @@ def anonymize_public_account(user_id: int) -> utils.BackofficeResponse:
flash("Une extraction de données est en cours pour cet utilisateur.", "warning")
return redirect(url_for(".get_public_account", user_id=user_id))

if is_beneficiary_anonymizable(user):
_anonymyze_user(user, current_user)
elif _is_only_beneficiary(user):
_pre_anonymize_user(user, current_user)
else:
raise BadRequest()

return redirect(url_for(".get_public_account", user_id=user_id))


def _anonymyze_user(user: users_models.User, author: users_models.User) -> None:
user_anonymized = users_api.anonymize_user(user, author=current_user, force=True)
if not user_anonymized:
if user_anonymized:
db.session.flush()
flash("Les informations de l'utilisateur ont été anonymisées", "success")
else:
mark_transaction_as_invalid()
flash("Une erreur est survenue lors de l'anonymisation de l'utilisateur", "warning")
return redirect(url_for(".get_public_account", user_id=user_id))

db.session.flush()

flash("Les informations de l'utilisateur ont été anonymisées", "success")
return redirect(url_for(".get_public_account", user_id=user_id))
def _pre_anonymize_user(user: users_models.User, author: users_models.User) -> None:
if _has_user_pending_anonymization(user.id):
mark_transaction_as_invalid()
flash("L'utilisateur est déjà en attente pour être anonymisé le jour de ses 21 ans", "warning")
else:
users_api.suspend_account(
user=user,
reason=users_constants.SuspensionReason.WAITING_FOR_ANONYMIZATION,
actor=author,
comment="L'utilisateur sera anonymisé le jour de ses 21 ans",
is_backoffice_action=True,
)
db.session.add(users_models.GdprUserAnonymization(user=user))
db.session.flush()
flash("L'utilisateur a été suspendu et sera anonymisé le jour de ses 21 ans", "success")


def _has_user_pending_anonymization(user_id: int) -> bool:
return db.session.query(
users_models.GdprUserAnonymization.query.filter(users_models.GdprUserAnonymization.userId == user_id).exists()
).scalar()


@public_accounts_blueprint.route("/search", methods=["GET"])
Expand Down Expand Up @@ -364,19 +398,18 @@ def render_public_account_details(
if utils.has_current_user_permission(perm_models.Permissions.EXTRACT_PUBLIC_ACCOUNT)
else None
),
"anonymize_form": (
empty_forms.EmptyForm()
if is_beneficiary_anonymizable(user)
and utils.has_current_user_permission(perm_models.Permissions.ANONYMIZE_PUBLIC_ACCOUNT)
else None
),
"anonymize_public_accounts_dst": url_for(".anonymize_public_account", user_id=user.id),
}
)

if not user.isEmailValidated:
kwargs["resend_email_validation_form"] = empty_forms.EmptyForm()

if utils.has_current_user_permission(
perm_models.Permissions.ANONYMIZE_PUBLIC_ACCOUNT
) and not _has_user_pending_anonymization(user_id):
kwargs["anonymize_form"] = empty_forms.EmptyForm()
kwargs["anonymize_public_accounts_dst"] = url_for(".anonymize_public_account", user_id=user.id)

fraud_reviews_desc = _get_fraud_reviews_desc(user.beneficiaryFraudReviews)
id_check_histories_desc = _get_id_check_histories_desc(eligibility_history)
tunnel = _get_tunnel(user, eligibility_history, fraud_reviews_desc)
Expand Down
38 changes: 32 additions & 6 deletions api/tests/routes/backoffice/accounts_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,9 +558,13 @@ class GetPublicAccountTest(GetEndpointHelper):
endpoint = "backoffice_web.public_accounts.get_public_account"
endpoint_kwargs = {"user_id": 1}
needed_permission = perm_models.Permissions.READ_PUBLIC_ACCOUNT

# session + current user + user data + bookings + Featureflag
expected_num_queries = 5
# session
# current user
# user data
# check if user is waiting to be anonymized
# bookings
# Featureflag
expected_num_queries = 6

class ReviewButtonTest(button_helpers.ButtonHelper):
needed_permission = perm_models.Permissions.BENEFICIARY_FRAUD_ACTIONS
Expand Down Expand Up @@ -1746,9 +1750,10 @@ class GetUserRegistrationStepTest(GetEndpointHelper):
# - session
# - current user
# - displayed user
# - check if user is waiting to be anonymized
# - user bookings separately
# - feature flag ENABLE_PHONE_VALIDATION
expected_num_queries = 5
expected_num_queries = 6

@pytest.mark.parametrize(
"age,id_check_type,expected_items_status_18,expected_id_check_status_18",
Expand Down Expand Up @@ -2579,8 +2584,29 @@ def test_anonymize_public_account_when_user_is_too_young(self, authenticated_cli
validatedBirthDate=datetime.datetime.today() - datetime.timedelta(days=365 * 21), roles=roles
)

response = self.post_to_endpoint(authenticated_client, user_id=user.id)
assert response.status_code == 400
response = self.post_to_endpoint(authenticated_client, user_id=user.id, follow_redirects=True)
assert response.status_code == 200

db.session.refresh(user)
assert not user.isActive
assert users_models.GdprUserAnonymization.query.filter_by(userId=user.id).count() == 1
assert "L'utilisateur a été suspendu et sera anonymisé le jour de ses 21 ans" in html_parser.extract_alert(
response.data
)

def test_anonymize_public_account_when_user_is_too_young_and_already_pending(self, authenticated_client):
user = users_factories.BeneficiaryFactory(
isActive=False, validatedBirthDate=datetime.datetime.today() - datetime.timedelta(days=365 * 21)
)
users_factories.GdprUserAnonymizationFactory(user=user)

response = self.post_to_endpoint(authenticated_client, user_id=user.id, follow_redirects=True)
assert response.status_code == 200

assert (
"L'utilisateur est déjà en attente pour être anonymisé le jour de ses 21 ans"
in html_parser.extract_alert(response.data)
)

def test_anonymize_public_account_when_user_has_no_deposit(self, authenticated_client):
user = users_factories.UserFactory()
Expand Down

0 comments on commit e1e569d

Please sign in to comment.