Skip to content

Commit

Permalink
(PC-34274)[API] feat: add age 17 to 18 eligibility
Browse files Browse the repository at this point in the history
  • Loading branch information
dnguyen1-pass committed Jan 30, 2025
1 parent d640d1c commit cb177e3
Show file tree
Hide file tree
Showing 26 changed files with 450 additions and 141 deletions.
4 changes: 2 additions & 2 deletions api/src/pcapi/core/finance/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3099,7 +3099,7 @@ def _has_celebrated_birthday_since_credit_or_registration(user: users_models.Use
if user.deposit and user.deposit.dateCreated and (user.deposit.dateCreated.date() < latest_birthday_date):
return True

first_registration_datetime = subscription_api.get_first_registration_date(
first_registration_datetime = subscription_api.get_first_registration_date_with_eligibility(
user, user.validatedBirthDate, users_models.EligibilityType.UNDERAGE
)
if first_registration_datetime is None:
Expand Down Expand Up @@ -3157,7 +3157,7 @@ def _get_known_age_at_deposit(user: users_models.User) -> int | None:
if known_birthday_at_deposit is None:
return None

first_registration_date = subscription_api.get_first_registration_date(
first_registration_date = subscription_api.get_first_registration_date_with_eligibility(
user, known_birthday_at_deposit, users_models.EligibilityType.UNDERAGE
)
if first_registration_date is not None:
Expand Down
2 changes: 1 addition & 1 deletion api/src/pcapi/core/fraud/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def get_eligibility_type_at_registration(self) -> users_models.EligibilityType |

postal_code = self.get_postal_code() # pylint: disable=assignment-from-none
department = postal_code_utils.PostalCode(postal_code).get_departement_code() if postal_code else None
eligibility_at_registration = eligibility_api.get_eligibility_at_date(
eligibility_at_registration = eligibility_api.get_extended_eligibility_at_date(
birth_date, registration_datetime, department
)

Expand Down
29 changes: 26 additions & 3 deletions api/src/pcapi/core/subscription/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def _get_age_at_first_registration(user: users_models.User, eligibility: users_m
if not user.birth_date:
return None

first_registration_date = get_first_registration_date(user, user.birth_date, eligibility)
first_registration_date = get_first_registration_date_with_eligibility(user, user.birth_date, eligibility)
if not first_registration_date:
return user.age

Expand Down Expand Up @@ -506,7 +506,7 @@ def requires_manual_review_before_activation(
return (
identity_fraud_check.type == fraud_models.FraudCheckType.DMS
and identity_fraud_check.status == fraud_models.FraudCheckStatus.OK
and not eligibility_api.get_eligibility_at_date(
and not eligibility_api.get_extended_eligibility_at_date(
user.birth_date, identity_fraud_check.get_min_date_between_creation_and_registration(), user.departementCode
)
)
Expand Down Expand Up @@ -747,7 +747,30 @@ def update_user_birth_date_if_not_beneficiary(user: users_models.User, birth_dat
pcapi_repository.repository.save(user)


def get_first_registration_date(
def get_first_registration_date_with_age(
user: users_models.User,
birth_date: datetime.date | None,
) -> datetime.datetime | None:
fraud_checks = user.beneficiaryFraudChecks
if not fraud_checks or not birth_date:
return None

def _is_user_eligible_at_fraud_check(fraud_check: fraud_models.BeneficiaryFraudCheck) -> bool:
age_at_fraud_check = users_utils.get_age_at_date(
birth_date, fraud_check.get_min_date_between_creation_and_registration(), user.departementCode
)
return 15 <= age_at_fraud_check <= 18

registration_dates_when_eligible = [
fraud_check.get_min_date_between_creation_and_registration()
for fraud_check in fraud_checks
if _is_user_eligible_at_fraud_check(fraud_check)
]

return min(registration_dates_when_eligible) if registration_dates_when_eligible else None


def get_first_registration_date_with_eligibility(
user: users_models.User,
birth_date: datetime.date | None,
eligibility: users_models.EligibilityType,
Expand Down
94 changes: 82 additions & 12 deletions api/src/pcapi/core/users/eligibility_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
from dateutil.relativedelta import relativedelta

from pcapi.core.subscription import api as subscription_api
from pcapi.core.users import constants
from pcapi.core.users import models as users_models
from pcapi.core.users import utils as users_utils
from pcapi.core.users import constants
from pcapi.models.feature import FeatureToggle


CREDIT_V3_DECREE_DATETIME = datetime.datetime(2025, 3, 3)


class EligibilityError(Exception):
Expand All @@ -21,10 +25,12 @@ def decide_eligibility(
It may be the current eligibility of the user if the age is between 15 and 18, or it may be the eligibility AGE18
if the user is over 19 and had previously tried to register when the age was 18.
"""

if birth_date is None:
return None

if FeatureToggle.WIP_ENABLE_CREDIT_V3.is_active():
return decide_v3_credit_eligibility(user, birth_date, registration_datetime)

user_age_today = users_utils.get_age_from_birth_date(birth_date, user.departementCode)
if user_age_today < 15:
return None
Expand All @@ -33,28 +39,70 @@ def decide_eligibility(
if user_age_today == 18:
return users_models.EligibilityType.AGE18

eligibility_today = get_eligibility_at_date(birth_date, datetime.datetime.utcnow(), user.departementCode)
eligibility_today = get_extended_eligibility_at_date(birth_date, datetime.datetime.utcnow(), user.departementCode)
if eligibility_today == users_models.EligibilityType.AGE18:
return users_models.EligibilityType.AGE18

eligibility_at_registration = (
get_eligibility_at_date(birth_date, registration_datetime, user.departementCode)
get_extended_eligibility_at_date(birth_date, registration_datetime, user.departementCode)
if registration_datetime
else None
)
if eligibility_at_registration is None and eligibility_today is None and user_age_today == 19:
earliest_identity_check_date = subscription_api.get_first_registration_date(
earliest_identity_check_date = subscription_api.get_first_registration_date_with_eligibility(
user, birth_date, users_models.EligibilityType.AGE18
)
if earliest_identity_check_date:
return get_eligibility_at_date(birth_date, earliest_identity_check_date, user.departementCode)
return get_extended_eligibility_at_date(birth_date, earliest_identity_check_date, user.departementCode)

return eligibility_at_registration


def get_eligibility_at_date(
def decide_v3_credit_eligibility(
user: users_models.User,
birth_date: datetime.date,
registration_datetime: datetime.datetime | None,
) -> users_models.EligibilityType | None:
"""
An user eligibility determines what type of deposit can be granted.
Returns the first eligibility found using:
1. the age at registration_datetime
2. the age at first registration, looking into the user's fraud checks
3. the current age.
This function assumes that the user.beneficiaryFraudChecks relation is already loaded.
"""
user_age = users_utils.get_age_from_birth_date(birth_date, user.departementCode)
if user_age < 15 or user_age > 20:
return None

eligibility: users_models.EligibilityType | None = None

if registration_datetime:
eligibility = get_extended_eligibility_at_date(birth_date, registration_datetime, user.departementCode)
if eligibility:
return eligibility

earliest_identity_check_date = subscription_api.get_first_registration_date_with_age(user, birth_date)
if earliest_identity_check_date:
eligibility = get_extended_eligibility_at_date(birth_date, earliest_identity_check_date, user.departementCode)
if eligibility:
return eligibility

if 17 <= user_age <= 18:
eligibility = users_models.EligibilityType.AGE17_18

return eligibility


def get_extended_eligibility_at_date(
date_of_birth: datetime.date | None, specified_datetime: datetime.datetime, department_code: str | None = None
) -> users_models.EligibilityType | None:
"""
Decide eligibility by computing the age at the specified datetime.
Extended eligibility means that this function considers a 19 year old eligible as long as the specified datetime
is contained between the eligibility datetime start and end.
"""
eligibility_start = get_eligibility_start_datetime(date_of_birth)
eligibility_end = get_eligibility_end_datetime(date_of_birth)

Expand All @@ -65,11 +113,15 @@ def get_eligibility_at_date(
if not age:
return None

if age in constants.ELIGIBILITY_UNDERAGE_RANGE:
return users_models.EligibilityType.UNDERAGE
# If the user is older than 18 in UTC timezone, we consider them eligible until they reach eligibility_end
if constants.ELIGIBILITY_AGE_18 <= age and specified_datetime < eligibility_end: # type: ignore[operator]
return users_models.EligibilityType.AGE18
if specified_datetime < CREDIT_V3_DECREE_DATETIME or not FeatureToggle.WIP_ENABLE_CREDIT_V3.is_active():
if age in constants.ELIGIBILITY_UNDERAGE_RANGE:
return users_models.EligibilityType.UNDERAGE
# If the user is older than 18 in UTC timezone, we consider them eligible until they reach eligibility_end
if constants.ELIGIBILITY_AGE_18 <= age and specified_datetime < eligibility_end: # type: ignore[operator]
return users_models.EligibilityType.AGE18

if FeatureToggle.WIP_ENABLE_CREDIT_V3.is_active() and age >= 17:
return users_models.EligibilityType.AGE17_18

return None

Expand Down Expand Up @@ -108,11 +160,29 @@ def is_eligibility_activable(user: users_models.User, eligibility: users_models.
def is_eligible_for_beneficiary_upgrade(
user: users_models.User, eligibility: users_models.EligibilityType | None
) -> bool:
if FeatureToggle.WIP_ENABLE_CREDIT_V3.is_active():
return is_eligible_for_next_recredit_activation_steps(user)
return (eligibility == users_models.EligibilityType.UNDERAGE and not user.is_beneficiary) or (
eligibility == users_models.EligibilityType.AGE18 and not user.has_beneficiary_role
)


def is_eligible_for_next_recredit_activation_steps(user: users_models.User) -> bool:
"""
Returns whether a user can undertake the activation steps to unlock the next deposit recredit
"""
if not user.age:
return False

if not user.is_beneficiary:
return 17 <= user.age

if not user.has_beneficiary_role:
return 18 <= user.age < 21

return False


def is_user_age_compatible_with_eligibility(
user_age: int | None, eligibility: users_models.EligibilityType | None
) -> bool:
Expand Down
2 changes: 2 additions & 0 deletions api/src/pcapi/core/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ class UserRole(enum.Enum):


class EligibilityType(enum.Enum):
AGE17_18 = "age-17-18"
# legacy eligibilities that are present in the database
UNDERAGE = "underage"
AGE18 = "age-18"

Expand Down
8 changes: 4 additions & 4 deletions api/src/pcapi/routes/backoffice/accounts/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,11 +333,11 @@ def render_public_account_details(
history = get_public_account_history(user)
duplicate_user_id = None
eligibility_history = get_eligibility_history(user)
user_current_eligibility = eligibility_api.get_eligibility_at_date(user.birth_date, datetime.datetime.utcnow())
user_current_eligibility = eligibility_api.get_extended_eligibility_at_date(
user.birth_date, datetime.datetime.utcnow()
)

if (
user_current_eligibility is not None and user_current_eligibility.value in eligibility_history
): # get_eligibility_at_date might return None
if user_current_eligibility is not None and user_current_eligibility.value in eligibility_history:
subscription_items = [
item
for item in eligibility_history[user_current_eligibility.value].subscriptionItems
Expand Down
4 changes: 2 additions & 2 deletions api/tests/core/bookings/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1895,8 +1895,8 @@ def test_num_queries(self):
educational_factories.CollectiveBookingFactory(collectiveStock__startDatetime=event_date)
educational_factories.CollectiveBookingFactory(collectiveStock__startDatetime=event_date)

queries = 1 # select feature flag
queries += 1 # select individual bookings
# queries = 1 # feature flags are already cached by BeneficiaryGrant18Factory.beneficiaryImports
queries = 1 # select individual bookings
queries += 1 # select individual booking user achievements
# fmt: off
queries += 2 * (
Expand Down
20 changes: 7 additions & 13 deletions api/tests/core/bookings/test_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -1371,7 +1371,6 @@ def _validate_csv_row(

@pytest.mark.features(WIP_ENABLE_OFFER_ADDRESS=True, WIP_USE_OFFERER_ADDRESS_AS_DATA_SOURCE=True)
def should_return_validated_bookings_for_offer(self):

beneficiary = users_factories.BeneficiaryGrant18Factory(
email="[email protected]", firstName="Ron", lastName="Weasley", postalCode="97300"
)
Expand Down Expand Up @@ -1402,7 +1401,7 @@ def should_return_validated_bookings_for_offer(self):
bookings_factories.BookingFactory(stock=stock_2)

queries = 0
queries += 1 # Get feature
# queries += 1 # feature flags are already cached by BeneficiaryGrant18Factory.beneficiaryImports
queries += 1 # Get bookings

offer_id = offer.id
Expand Down Expand Up @@ -1459,7 +1458,7 @@ def should_return_validated_bookings_for_offer_with_old_cancelled_booking(self):
bookings_factories.BookingFactory(stock=stock_2)

queries = 0
queries += 1 # Get feature
# queries += 1 # feature flags are already cached by BeneficiaryGrant18Factory.beneficiaryImports
queries += 1 # Get bookings

offer_id = offer.id
Expand Down Expand Up @@ -1684,7 +1683,6 @@ def _validate_csv_row(
assert data_dict["Duo"] == duo

def should_return_validated_bookings_for_offer(self):

beneficiary = users_factories.BeneficiaryGrant18Factory(
email="[email protected]", firstName="Ron", lastName="Weasley", postalCode="97300"
)
Expand Down Expand Up @@ -1715,7 +1713,7 @@ def should_return_validated_bookings_for_offer(self):
bookings_factories.BookingFactory(stock=stock_2)

queries = 0
queries += 1 # Get feature
# queries += 1 # feature flags are already cached by BeneficiaryGrant18Factory.beneficiaryImports
queries += 1 # Get bookings

offer_id = offer.id
Expand All @@ -1737,7 +1735,6 @@ def should_return_validated_bookings_for_offer(self):
)

def should_return_validated_bookings_for_offer_with_offerer_address_as_data_source(self):

beneficiary = users_factories.BeneficiaryGrant18Factory(
email="[email protected]", firstName="Ron", lastName="Weasley", postalCode="97300"
)
Expand Down Expand Up @@ -1768,7 +1765,7 @@ def should_return_validated_bookings_for_offer_with_offerer_address_as_data_sour
bookings_factories.BookingFactory(stock=stock_2)

queries = 0
queries += 1 # Get feature
# queries += 1 # feature flags are already cached by BeneficiaryGrant18Factory.beneficiaryImports
queries += 1 # Get bookings

offer_id = offer.id
Expand All @@ -1790,7 +1787,6 @@ def should_return_validated_bookings_for_offer_with_offerer_address_as_data_sour
)

def should_return_validated_bookings_for_offer_with_old_cancelled_booking(self):

beneficiary = users_factories.BeneficiaryGrant18Factory(
email="[email protected]", firstName="Ron", lastName="Weasley", postalCode="97300"
)
Expand Down Expand Up @@ -1824,7 +1820,7 @@ def should_return_validated_bookings_for_offer_with_old_cancelled_booking(self):
bookings_factories.BookingFactory(stock=stock_2)

queries = 0
queries += 1 # Get feature
# queries += 1 # feature flags are already cached by BeneficiaryGrant18Factory.beneficiaryImports
queries += 1 # Get bookings

offer_id = offer.id
Expand Down Expand Up @@ -2073,7 +2069,6 @@ def _validate_excel_row(
assert sheet.cell(row=row, column=18).value == duo

def should_return_validated_bookings_for_offer_with_offerer_address_as_data_source(self):

beneficiary = users_factories.BeneficiaryGrant18Factory(
email="[email protected]", firstName="Ron", lastName="Weasley", postalCode="97300"
)
Expand Down Expand Up @@ -2105,7 +2100,7 @@ def should_return_validated_bookings_for_offer_with_offerer_address_as_data_sour

queries = 0
queries += 1 # select offer
queries += 1 # select feature
# queries += 1 # feature flags are already cached by BeneficiaryGrant18Factory.beneficiaryImports
queries += 1 # select booking

with assert_num_queries(queries):
Expand Down Expand Up @@ -2423,7 +2418,6 @@ def _validate_excel_row(
assert sheet.cell(row=row, column=18).value == duo

def should_return_validated_bookings_for_offer(self):

beneficiary = users_factories.BeneficiaryGrant18Factory(
email="[email protected]", firstName="Ron", lastName="Weasley", postalCode="97300"
)
Expand Down Expand Up @@ -2455,7 +2449,7 @@ def should_return_validated_bookings_for_offer(self):

queries = 0
queries += 1 # select offer
queries += 1 # select feature
# queries += 1 # feature flags are already cached by BeneficiaryGrant18Factory.beneficiaryImports
queries += 1 # select booking

with assert_num_queries(queries):
Expand Down
2 changes: 2 additions & 0 deletions api/tests/core/external/external_pro_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ def test_update_external_pro_user_attributes(
)

num_queries = EXPECTED_PRO_ATTR_NUM_QUERIES
if create_booking:
num_queries -= 1 # feature flags are already cached by BeneficiaryGrant18Factory.beneficiaryImports

with assert_num_queries(num_queries):
attributes = get_pro_attributes(email)
Expand Down
Loading

0 comments on commit cb177e3

Please sign in to comment.