Skip to content

Api reference

components.gamification.public.business_logic

actions

player

merge_users_in_gamification_component
merge_users_in_gamification_component(
    source_user_id, target_user_id, commit=True
)

Support function for the "merge user" procedure: changes the source player user id to the target user id.

The targeted user id cannot already be a player, in this case this will fail

Source code in components/gamification/internal/business_logic/actions/player.py
def merge_users_in_gamification_component(
    source_user_id: str,
    target_user_id: str,
    commit: bool = True,
) -> None:
    """
    Support function for the "merge user" procedure: changes the source player user id
    to the target user id.

    The targeted user id cannot already be a player, in this case this will fail
    """
    from components.gamification.internal.models.player import Player

    source_player = current_session.scalars(
        select(Player).where(Player.app_user_id == source_user_id)
    ).one_or_none()

    if source_player:
        source_player.app_user_id = target_user_id

        if commit:
            current_session.commit()
        else:
            current_session.flush()

        current_logger.info(
            f"Updated player {source_player.id} app user id to {target_user_id}"
        )
    else:
        current_logger.info(f"No player found for {source_user_id}")

components.gamification.public.commands

app_group

gamification_commands module-attribute

gamification_commands = AppGroup('gamification')

cleanup_duels

cleanup_duels

cleanup_duels(dry_run)

Cleanup duels that never had winners (usually if the player never opens the duel after results are ready) This also refunds both players

Source code in components/gamification/public/commands/cleanup_duels.py
@gamification_commands.command(requires_authentication=False)
@command_with_dry_run
def cleanup_duels(dry_run: bool) -> None:
    """
    Cleanup duels that never had winners (usually if the player never opens the duel after results are ready)
    This also refunds both players
    """
    from components.gamification.internal.business_logic.actions.duels import (
        cleanup_old_duels_without_winners,
    )

    cleanup_old_duels_without_winners(
        dry_run=dry_run, older_than_timedelta=timedelta(days=5)
    )

cleanup_player_info

cleanup_players

cleanup_players

cleanup_players(dry_run)

Cleanup players (eg. those who are not insured anymore)

Source code in components/gamification/public/commands/cleanup_players.py
@gamification_commands.command(requires_authentication=False)
@command_with_dry_run
def cleanup_players(dry_run: bool) -> None:
    """
    Cleanup players (eg. those who are not insured anymore)
    """
    from components.gamification.internal.business_logic.actions.player import (
        cleanup_players,
    )

    cleanup_players(dry_run=dry_run)

compute_month_winners

compute_month_winners

compute_month_winners(
    dry_run,
    app_id,
    expected_paris_hour=None,
    expected_toronto_hour=None,
)

Compute monthly winners: - Team Challenge

Source code in components/gamification/public/commands/compute_month_winners.py
@gamification_commands.command(requires_authentication=False)
@command_with_dry_run
@click.option("--app-id", type=click.STRING, required=True)
@click.option("--expected-paris-hour", type=click.INT, required=False)
@click.option("--expected-toronto-hour", type=click.INT, required=False)
def compute_month_winners(
    dry_run: bool,
    app_id: str,
    expected_paris_hour: Optional[int] = None,
    expected_toronto_hour: Optional[int] = None,
) -> None:
    """
    Compute monthly winners:
    - Team Challenge
    """
    from components.gamification.internal.business_logic.actions.team_challenge import (
        compute_team_challenge_winners,
    )

    # Check it's expected time and last day of the month

    if expected_paris_hour:
        paris_time = datetime.now(ZoneInfo("Europe/Paris"))
        if expected_paris_hour != paris_time.hour:
            current_logger.info(
                "Not expected Paris hour for winners consolidation",
                paris_current=paris_time.hour,
                paris_expected=expected_paris_hour,
            )
            return
        if paris_time.month == (paris_time + timedelta(days=1)).month:
            current_logger.info(
                "Not the last day of the month in Paris timezone",
                current_paris_date=paris_time.date(),
            )
            return
    if expected_toronto_hour:
        toronto_time = datetime.now(ZoneInfo("America/Toronto"))
        if expected_toronto_hour != toronto_time.hour:
            current_logger.info(
                "Not expected Toronto hour for winners consolidation",
                toronto_current=toronto_time.hour,
                toronto_expected=expected_toronto_hour,
            )
            return
        if toronto_time.month == (toronto_time + timedelta(days=1)).month:
            current_logger.info(
                "Not the last day of the month in Paris timezone",
                current_paris_date=toronto_time.date(),
            )
            return

    compute_team_challenge_winners(app_id=app_id, dry_run=dry_run)

compute_week_winners

compute_week_global_podium

compute_week_global_podium(
    dry_run, expected_paris_hour=None
)

Find the global podium for the week amongst all alan members

Source code in components/gamification/public/commands/compute_week_winners.py
@gamification_commands.command(requires_authentication=False)
@command_with_dry_run
@click.option("--expected-paris-hour", type=click.INT, required=False)
def compute_week_global_podium(
    dry_run: bool, expected_paris_hour: Optional[int] = None
) -> None:
    """
    Find the global podium for the week amongst all alan members
    """
    paris_hour = datetime.now(ZoneInfo("Europe/Paris")).hour

    current_logger.info(
        "Weekly global podium calculations",
        paris_current=paris_hour,
        paris_expected=expected_paris_hour,
    )

    # The command is scheduled twice (at 18h and 19h UTC) to support summer time change because we want it to execute
    # exactly at 20h in reference time whatever the month, while crons declaration only support UTC
    if expected_paris_hour and expected_paris_hour != paris_hour:
        current_logger.warn(
            "Not expected Paris hour for global weekly podium calculations"
        )
        return

    compute_and_notify_weekly_global_podium(dry_run=dry_run)

compute_week_winners

compute_week_winners(
    dry_run,
    app_id,
    expected_paris_hour=None,
    expected_toronto_hour=None,
)

Compute week winners - Walk Leaderboard

Source code in components/gamification/public/commands/compute_week_winners.py
@gamification_commands.command(requires_authentication=False)
@command_with_dry_run
@click.option("--app-id", type=click.STRING, required=True)
@click.option("--expected-paris-hour", type=click.INT, required=False)
@click.option("--expected-toronto-hour", type=click.INT, required=False)
def compute_week_winners(
    dry_run: bool,
    app_id: str,
    expected_paris_hour: Optional[int] = None,
    expected_toronto_hour: Optional[int] = None,
) -> None:
    """
    Compute week winners
    - Walk Leaderboard
    """
    from components.gamification.internal.business_logic.actions.walk_leaderboard import (
        compute_leaderboards_winners,
    )

    # Check it's expected time

    if expected_paris_hour:
        paris_hour = datetime.now(ZoneInfo("Europe/Paris")).hour
        if expected_paris_hour != paris_hour:
            current_logger.warn(
                "Not expected Paris hour for winners consolidation",
                paris_current=paris_hour,
                paris_expected=expected_paris_hour,
            )
            return
    if expected_toronto_hour:
        toronto_hour = datetime.now(ZoneInfo("America/Toronto")).hour
        if expected_toronto_hour != toronto_hour:
            current_logger.warn(
                "Not expected Toronto hour for winners consolidation",
                toronto_current=toronto_hour,
                toronto_expected=expected_toronto_hour,
            )
            return

    compute_leaderboards_winners(app_id=app_id, dry_run=dry_run)

compute_week_winners_retroactively

compute_week_winners_retroactively(dry_run, app_id, day)

Compute walk league week winners retroactively

Source code in components/gamification/public/commands/compute_week_winners.py
@gamification_commands.command(requires_authentication=False)
@command_with_dry_run
@click.option("--app-id", type=click.STRING, required=True)
@click.option("--day", type=click.STRING, required=True)
def compute_week_winners_retroactively(dry_run: bool, app_id: str, day: str) -> None:
    """
    Compute walk league week winners retroactively
    """
    from components.gamification.internal.business_logic.actions.walk_leaderboard import (
        compute_leaderboards_winners_retroactively,
    )

    day_as_date = isoparse(day)

    current_logger.info(
        "League winners consolidation (retroactively)",
        app_id=app_id,
        day=day_as_date,
    )

    compute_leaderboards_winners_retroactively(
        dry_run=dry_run, app_id=app_id, day=day_as_date
    )

eligibility

COUNTRIES_TO_APP_ID module-attribute

COUNTRIES_TO_APP_ID = {
    "be": ALAN_BE,
    "ca": ALAN_CA,
    "es": ALAN_ES,
    "fr": ALAN_FR,
}

auto_register_future_accounts_for_gamification

auto_register_future_accounts_for_gamification(
    dry_run=False,
)

Create or update gamification eligibilities

Source code in components/gamification/public/commands/eligibility.py
@gamification_commands.command(requires_authentication=False)
@command_with_dry_run
def auto_register_future_accounts_for_gamification(dry_run: bool = False) -> None:
    """
    Create or update gamification eligibilities
    """
    _create_eligibilities(dry_run=dry_run)
    _update_league_eligibilities(dry_run=dry_run)
    _update_duel_eligibilities(dry_run=dry_run)

reset_player_info

reset_player_info

reset_player_info(app_user_id, dry_run=True)

Removes all gamification info linked to a player following withdrawal of consent

Source code in components/gamification/public/commands/reset_player_info.py
@gamification_commands.command(requires_authentication=False)
@command_with_dry_run
@click.option(
    "--app-user-id",
    type=click.STRING,
    required=True,
    help="App user ID of the player to clean up",
)
def reset_player_info(app_user_id: str, dry_run: bool = True) -> None:
    """
    Removes all gamification info linked to a player following withdrawal of consent
    """
    from components.gamification.public.services.player import remove_player_data

    remove_player_data(app_user_id, dry_run)

resync_players_preferences_with_customerio

resync_players_preferences_with_customerio

resync_players_preferences_with_customerio(dry_run=False)

Resync all players preferences with customer CIO

Source code in components/gamification/public/commands/resync_players_preferences_with_customerio.py
@gamification_commands.command(requires_authentication=False)
@command_with_dry_run
def resync_players_preferences_with_customerio(dry_run: bool = False) -> None:
    """
    Resync all players preferences with customer CIO
    """
    from components.gamification.internal.models.player import Player
    from components.gamification.internal.services.tracking import gamification_events

    players = current_session.execute(
        select(
            Player.app_user_id,
            Player.walk_start_date,
            Player.enabled_duels_at,
            Player.enabled_reactions_at,
            Player.enabled_beneficiary_league_at,
            Player.enabled_marketing_notifications_at,
            Player.enabled_leaderboard_notifications_at,
            Player.enabled_berries_notifications_at,
            Player.enabled_social_notifications_at,
            Player.disabled_external_activity_notifications_at,
        )
        .filter(
            Player.walk_start_date.isnot(None),
        )
        .order_by(Player.created_at)
    ).all()

    count_walk_started = sum(1 for p in players if p[1] is not None)
    count_duels_enabled = sum(1 for p in players if p[2] is not None)
    count_reactions_enabled = sum(1 for p in players if p[3] is not None)
    count_beneficiary_leagues_enabled = sum(1 for p in players if p[4] is not None)
    count_marketing_notif_enabled = sum(1 for p in players if p[5] is not None)
    count_leaderboard_notif_enabled = sum(1 for p in players if p[6] is not None)
    count_berries_notif_enabled = sum(1 for p in players if p[7] is not None)
    count_social_notif_enabled = sum(1 for p in players if p[8] is not None)

    click.echo(
        f"Counts: {count_walk_started=} "
        f"{count_duels_enabled=}"
        f"{count_reactions_enabled=}"
        f"{count_beneficiary_leagues_enabled=}"
        f"{count_marketing_notif_enabled=}"
        f"{count_leaderboard_notif_enabled=}"
        f"{count_berries_notif_enabled=}"
        f"{count_social_notif_enabled=}"
    )

    for index, (
        app_user_id,
        _,
        enabled_duels_at,
        enabled_reactions_at,
        enabled_beneficiary_league_at,
        enabled_marketing_notifications_at,
        enabled_leaderboard_notifications_at,
        enabled_berries_notifications_at,
        enabled_social_notifications_at,
        disabled_external_activity_notifications_at,
    ) in enumerate(players):
        if (index + 1) % 1000 == 0:
            click.echo(f"PROCESSED {index + 1}")

        traits = {
            "duels_enabled": 1 if enabled_duels_at is not None else 0,
            "reactions_enabled": 1 if enabled_reactions_at is not None else 0,
            "beneficiary_league_enabled": 1
            if enabled_beneficiary_league_at is not None
            else 0,
            "walk_notifications_enabled": 1
            if enabled_marketing_notifications_at is not None
            else 0,
            "play_leaderboard_notifications_enabled": 1
            if enabled_leaderboard_notifications_at is not None
            else 0,
            "play_berries_notifications_enabled": 1
            if enabled_berries_notifications_at is not None
            else 0,
            "play_social_notifications_enabled": 1
            if enabled_social_notifications_at is not None
            else 0,
            "play_external_activity_notifications_disabled": 1
            if disabled_external_activity_notifications_at is not None
            else 0,
        }

        if dry_run:
            click.echo(f"Traits of {app_user_id}: {traits}")
        else:
            gamification_events._tracking_client.identify(  # noqa: ALN027
                user_id=app_user_id,
                traits=traits,
            )

team_challenge

cleanup_team_challenges

cleanup_team_challenges(dry_run=False)

Cleanup team challenges

For challenges that are started - cleanup teams that have been created automatically at initialization but have only one player assigned and are not customized

Source code in components/gamification/public/commands/team_challenge.py
@gamification_commands.command(requires_authentication=False)
@command_with_dry_run
def cleanup_team_challenges(dry_run: bool = False) -> None:
    """
    Cleanup team challenges

    For challenges that are started
    - cleanup teams that have been created automatically at initialization but have only one player assigned and are not customized
    """
    from components.gamification.internal.business_logic.actions.team_challenge import (
        cleanup_team_challenges as _cleanup_team_challenges,
    )

    _cleanup_team_challenges(dry_run=dry_run)

initialize_team_challenges

initialize_team_challenges(dry_run=False)

Initialize team challenges

For accounts that are declared with a start date but not set yet: - initialize teams_size from number of enrolled players - generate default teams and captain assignation

Source code in components/gamification/public/commands/team_challenge.py
@gamification_commands.command(requires_authentication=False)
@command_with_dry_run
def initialize_team_challenges(dry_run: bool = False) -> None:
    """
    Initialize team challenges

    For accounts that are declared with a start date but not set yet:
    - initialize teams_size from number of enrolled players
    - generate default teams and captain assignation
    """
    from components.gamification.internal.business_logic.actions.team_challenge import (
        initialize_team_challenges as _initialize_team_challenges,
    )

    _initialize_team_challenges(dry_run=dry_run)

vouchers

reconsolidate_gamification_vouchers_state

reconsolidate_gamification_vouchers_state(dry_run=False)

Reconsolidate gamification vouchers state based on shopify discounts state - If discount code appears in a not cancelled order, the command will ensure the related voucher is marked as used - If discount code appears in a cancelled order, the command will ensure the related voucher is re-regenerated and not marked as used

Source code in components/gamification/public/commands/vouchers.py
@gamification_commands.command(requires_authentication=False)
@command_with_dry_run
def reconsolidate_gamification_vouchers_state(dry_run: bool = False) -> None:
    """
    Reconsolidate gamification vouchers state based on shopify discounts state
    - If discount code appears in a not cancelled order, the command will ensure the related voucher is marked as used
    - If discount code appears in a cancelled order, the command will ensure the related voucher is re-regenerated and not marked as used
    """
    from components.gamification.internal.helpers.vouchers import (
        VOUCHER_CODE_PROPS,
        generate_discount_code,
    )
    from components.gamification.internal.models.voucher import Voucher
    from components.gamification.public.enums.voucher import VoucherCode
    from components.shop.public.services.discounts import (
        create_shop_discount,
        get_processed_orders_with_discounts,
    )

    applied_discounts: list[str] = []
    cancelled_discounts: list[str] = []
    after = date.today() - timedelta(days=5)

    for order in get_processed_orders_with_discounts(after=after):
        discounts = order.discount_codes
        if discounts:
            if order.cancelled_at:
                cancelled_discounts.extend(discounts)
            else:
                applied_discounts.extend(discounts)

    count_applied_discounts = len(applied_discounts)
    count_cancelled_discounts = len(cancelled_discounts)
    if not count_applied_discounts and not count_cancelled_discounts:
        current_logger.info("No discounts to process")
        return

    current_logger.info(
        "Found discounts to process",
        count_applied=count_applied_discounts,
        count_cancelled=count_cancelled_discounts,
    )

    # HANDLE DEACTIVATIONS

    vouchers_to_deactivate = [
        row[0]
        for row in (
            current_session.query(Voucher.id)  # noqa: ALN085
            .filter(
                Voucher.discount_code.in_(applied_discounts),
                Voucher.used_at.is_(None),
            )
            .all()
        )
    ]

    count_to_deactivate = len(vouchers_to_deactivate)
    if count_to_deactivate > 0:
        current_logger.info("Found vouchers to deactivate", count=count_to_deactivate)

        current_session.query(Voucher).filter(  # noqa: ALN085
            Voucher.id.in_(vouchers_to_deactivate)
        ).update(
            {Voucher.used_at: datetime.utcnow(), Voucher.updated_at: datetime.utcnow()},
            synchronize_session=False,
        )
        if dry_run:
            current_session.rollback()
        else:
            current_session.commit()
            # TODO: Send slack notif?
    else:
        current_logger.info("No vouchers to deactivate")

    # HANDLE REGENERATIONS

    vouchers_to_regenerate: list[Voucher] = (
        current_session.query(Voucher)  # noqa: ALN085
        .filter(
            Voucher.discount_code.in_(cancelled_discounts),
            Voucher.used_at.isnot(None),
        )
        .all()
    )

    count_to_regenerate = len(vouchers_to_regenerate)
    if count_to_regenerate > 0:
        current_logger.info("Found vouchers to regenerate", count=count_to_regenerate)

        for voucher in vouchers_to_regenerate:
            current_logger.info(
                "Regenerating voucher", voucher_id=voucher.id, voucher_code=voucher.code
            )

            amount_in_euros, _ = VOUCHER_CODE_PROPS[VoucherCode[voucher.code]]

            title_suffix = "" if is_production_mode() else f" - {get_env_name()}"

            discount_code = generate_discount_code()

            try:
                create_shop_discount(
                    code=discount_code,
                    amount=amount_in_euros,
                    expires_at=voucher.expires_at,
                    title=f"Voucher {amount_in_euros}€ - {discount_code}"
                    + title_suffix,
                )
            except Exception as e:
                current_logger.error("Failed to create shop discount", error=e)
                continue

            voucher.discount_code = discount_code  # Apply new discount code
            voucher.used_at = None  # Reset used_at

        if dry_run:
            current_session.rollback()
        else:
            current_session.commit()
            # TODO: Send slack notif?
    else:
        current_logger.info("No vouchers to regenerate")

components.gamification.public.controllers

achievements

AchievementsController

Bases: BaseController

WHITELISTED_CLIENT_ACHIEVEMENT_CODES module-attribute

WHITELISTED_CLIENT_ACHIEVEMENT_CODES = {
    activation_wheel,
    baby_sleep_completed,
    backpain_completed,
    debug,
    stress_program_completed,
    play_scratch,
    play_scratch_25_1,
    play_scratch_25_2,
    play_scratch_25_3,
    play_scratch_25_4,
    play_scratch_tp_card,
    streak_7d,
    streak_20d,
    streak_50d,
    streak_100d,
    streak_150d,
    streak_200d,
    streak_250d,
    streak_300d,
    streak_365d,
    streak_500d,
    shop_eyewear_vto_frames_tried,
    tp_card_added_to_wallet,
    mo_first_discussion,
}

achievements_endpoint module-attribute

achievements_endpoint = Endpoint('achievements')

activities

ActivitiesController

Bases: BaseController

activities_endpoint module-attribute

activities_endpoint = Endpoint('activities')

badges

BadgesController

Bases: BaseController

badges_endpoint module-attribute

badges_endpoint = Endpoint('badges')

get_badges

get_badges()
Source code in components/gamification/public/controllers/badges.py
@deprecated("No longer used starting with v1.358", category=AlanDeprecationWarning)
@BadgesController.action_route(
    "",
    methods=["GET"],
    auth_strategy=GlobalAuthorizationStrategies().authenticated(),
)
@obs.api_call()
def get_badges() -> Response:  # noqa: D103
    return make_json_response(
        {"badges": [], "badges_last_displayed_at": datetime.now()}
    )

mark_badges_as_seen

mark_badges_as_seen(player_context, params)

Mark some badges as seen in the frontend. This ensures we don't show them again in the future.

Source code in components/gamification/public/controllers/badges.py
@BadgesController.action_route(
    "/mark_as_displayed",
    methods=["POST"],
    auth_strategy=GlobalAuthorizationStrategies().authenticated(),
)
@request_argument(
    "displayed_at",
    type=datetime.fromisoformat,
    help="Timestamp of the newest badge displayed",
    required=True,
)
@inject_player_context
@obs.api_call()
def mark_badges_as_seen(player_context: PlayerContextData, params: dict) -> Response:  # type: ignore[type-arg]
    """
    Mark some badges as seen in the frontend. This ensures we don't show them again in the future.
    """
    from components.gamification.internal.business_logic.actions.player import (
        update_player,
    )

    update_player(
        player_id=player_context.player_id,
        badges_last_displayed_at=params["displayed_at"],
    )

    return make_empty_response()

charity

CharitiesController

Bases: BaseController

charity_endpoint module-attribute

charity_endpoint = Endpoint('charity')

daily_challenge

DailyChallengeController

Bases: BaseController

daily_challenge_endpoint module-attribute

daily_challenge_endpoint = Endpoint('daily_challenge')

duels

CreateDuelsParamsDict

Bases: TypedDict

berries_amount instance-attribute
berries_amount
receiver_id instance-attribute
receiver_id

DuelsController

Bases: BaseController

Controller for handling duels.

This controller provides endpoints to create a duel, get a list of duels for a player, get a single duel by ID, and mark a duel as accepted.

walk_duels_endpoint module-attribute

walk_duels_endpoint = Endpoint('duels')

level

LevelController

Bases: BaseController

level_endpoint module-attribute

level_endpoint = Endpoint('level')

marmot

CARE_ACHIEVEMENT_CODES module-attribute

CARE_ACHIEVEMENT_CODES = {
    care_reward,
    care_incompatible_device,
}

MarmotController

Bases: BaseController

VALID_BERRIES_POINTS module-attribute

VALID_BERRIES_POINTS = {25, 50, 100, 200}

marmot_endpoint module-attribute

marmot_endpoint = Endpoint('marmot')

play_history

PlayHistoryController

Bases: BaseController

play_history_endpoint module-attribute

play_history_endpoint = Endpoint('play_history')

player

PlayerController

Bases: BaseController

player_endpoint module-attribute

player_endpoint = Endpoint('player')

players

MAX_PLAYERS_LIST_LIMIT module-attribute

MAX_PLAYERS_LIST_LIMIT = 1000

PlayersController

Bases: BaseController

players_endpoint module-attribute

players_endpoint = Endpoint('players')

points

PointsController

Bases: BaseController

points_endpoint module-attribute

points_endpoint = Endpoint('points')

purchases

PurchasesController

Bases: BaseController

get_purchases

get_purchases(player_context)
Source code in components/gamification/public/controllers/purchases.py
@PurchasesController.action_route(
    "",
    methods=["GET"],
    auth_strategy=GlobalAuthorizationStrategies().authenticated(),
)
@inject_player_context
@obs.api_call()
def get_purchases(player_context: PlayerContextData) -> Response:  # noqa: D103
    from components.gamification.internal.business_logic.queries.purchases import (
        list_purchases,
    )

    return make_json_response(list_purchases(player_id=player_context.player_id))

purchases_endpoint module-attribute

purchases_endpoint = Endpoint('purchases')

reactions

ReactionsController

Bases: BaseController

Controller for handling reactions.

This controller provides an endpoint to submit reactions from users. It accepts a POST request with the receiver's ID and the emoji representing the reaction. The reaction is then stored using the internal business logic.

reactions_endpoint module-attribute

reactions_endpoint = Endpoint('reactions')

records

RecordsController

Bases: BaseController

Handles requests related to personal records (e.g. most steps in a day, longest streak..)

get_records

get_records(player_context, params)

Returns the 4 current types of personal records (steps, streak, leaderboard position, berries)

Source code in components/gamification/public/controllers/records.py
@RecordsController.action_route(
    "",
    methods=["GET"],
    auth_strategy=GlobalAuthorizationStrategies().authenticated(),
)
@request_argument(
    "app_user_id",
    type=str,
    required=False,
    owner_controller=NoOwner,
)
@inject_player_context
@obs.api_call()
def get_records(player_context: PlayerContextData, params: dict[str, str]) -> Response:
    """
    Returns the 4 current types of personal records (steps, streak, leaderboard position, berries)
    """
    from components.gamification.public.helpers.player_authorisation import (
        get_other_player_context_if_authorised,
    )

    other_player_context = get_other_player_context_if_authorised(
        current_player_context=player_context,
        other_user_id=params.get("app_user_id"),
    )
    if not other_player_context:
        return make_json_response(
            {
                "steps_record": 0,
                "points_record": 0,
                "streak_record": 0,
                "leaderboard_position_record": 0,
            }
        )

    steps_record = get_daily_steps_record_for_player(
        player_id=other_player_context.player_id,
        walk_start_date=other_player_context.walk_start_date,
    )
    points_record = get_daily_points_record_for_player(
        player_id=other_player_context.player_id
    )
    streak_record = get_longest_streak_record_for_player(
        player_id=other_player_context.player_id
    )
    leaderboard_position_record = get_best_position_record_for_player(
        account_id=other_player_context.account_id,
        company_id=other_player_context.company_id,
        player_id=other_player_context.player_id,
    )

    return make_json_response(
        {
            "steps_record": steps_record,
            "points_record": points_record,
            "streak_record": streak_record,
            "leaderboard_position_record": leaderboard_position_record,
        }
    )

records_endpoint module-attribute

records_endpoint = Endpoint('records')

stats

StatsController

Bases: BaseController

Handles requests related to player stats (e.g. total steps today, this week, this month, this year)

walk_stats_endpoint module-attribute

walk_stats_endpoint = Endpoint('stats')

stores

StoresController

Bases: BaseController

stores_endpoint module-attribute

stores_endpoint = Endpoint('stores')

streaks

StreaksController

Bases: BaseController

streaks_endpoint module-attribute

streaks_endpoint = Endpoint('streaks')

team_challenge

TeamChallengeController

Bases: BaseController

team_challenge_endpoint module-attribute

team_challenge_endpoint = Endpoint('team_challenge')

users

UsersController

Bases: BaseController

Controller for user-related needs in the gamification module. Currently used to search users who aren't enrolled in Play

users_endpoint module-attribute

users_endpoint = Endpoint('users')

utils

inject_player_context

inject_player_context(f)
Source code in components/gamification/public/controllers/utils.py
def inject_player_context(f: Callable) -> Callable:  # type: ignore[type-arg]  # noqa: D103
    method_signature = signature(f)

    @wraps(f)
    def decorated_function(*args, **kwargs):  # type: ignore[no-untyped-def]
        from components.gamification.internal.business_logic.actions.player import (
            get_or_create_player,
        )
        from components.gamification.internal.business_logic.queries.player import (
            get_player_context,
            get_player_context_from_user_id_cached,
        )

        user = g.current_user if hasattr(g, "current_user") else None
        if not user or not user.feature_user:
            current_logger.exception("Gamification endpoint expect a user")
            abort(400, "No user")

        feature_user = user.feature_user
        app_id = feature_user.app_id
        app_user_id = feature_user.app_user_id

        player_context = get_player_context_from_user_id_cached(app_user_id=app_user_id)
        if player_context:
            if "account_id" in method_signature.parameters:
                kwargs["account_id"] = player_context.account_id
            if "company_id" in method_signature.parameters:
                kwargs["company_id"] = (
                    player_context.company_id
                )  # relative to entity configuration not to user enrollment
            if "player_context" in method_signature.parameters:
                kwargs["player_context"] = player_context
            if "player_id" in method_signature.parameters:
                kwargs["player_id"] = player_context.player_id
        else:
            player = get_or_create_player(app_id=app_id, app_user_id=app_user_id)
            player_context = get_player_context(player)
            if "account_id" in method_signature.parameters:
                kwargs["account_id"] = player.account_id
            if "company_id" in method_signature.parameters:
                kwargs["company_id"] = (
                    player_context.company_id
                )  # relative to entity configuration not to user enrollment
            if "player_context" in method_signature.parameters:
                kwargs["player_context"] = player_context
            if "player_id" in method_signature.parameters:
                kwargs["player_id"] = player.id

        if "app_id" in method_signature.parameters:
            kwargs["app_id"] = app_id
        if "app_user_id" in method_signature.parameters:
            kwargs["app_user_id"] = app_user_id

        return f(*args, **kwargs)

    return decorated_function

vouchers

VouchersController

Bases: BaseController

vouchers_endpoint module-attribute

vouchers_endpoint = Endpoint('vouchers')

walk_challenge

WalkChallengeController

Bases: BaseController

walk_challenge_endpoint module-attribute

walk_challenge_endpoint = Endpoint('walking_challenge')

walk_history

WalkHistoryController

Bases: BaseController

walk_history_endpoint module-attribute

walk_history_endpoint = Endpoint('walking_history')

walk_leagues

WalkLeaguesController

Bases: BaseController

walk_leagues_endpoint module-attribute

walk_leagues_endpoint = Endpoint('walk_leagues')

components.gamification.public.dependency

BeneficiaryData dataclass

BeneficiaryData(user_id, first_name)

Bases: DataClassJsonMixin

Data for a single beneficiary.

first_name instance-attribute

first_name

user_id instance-attribute

user_id

COMPONENT_NAME module-attribute

COMPONENT_NAME = 'gamification'

GamificationAccountAdminEnrollmentData dataclass

GamificationAccountAdminEnrollmentData(
    account_ids, account_names, company_ids, company_names
)

...

account_ids instance-attribute

account_ids

account_names instance-attribute

account_names

company_ids instance-attribute

company_ids

company_names instance-attribute

company_names

GamificationDependency

Bases: ABC

get_account_admin_enrollment abstractmethod

get_account_admin_enrollment(app_user_id)

...

Source code in components/gamification/public/dependency.py
@abstractmethod
def get_account_admin_enrollment(
    self, app_user_id: str
) -> GamificationAccountAdminEnrollmentData | None:
    """..."""

get_account_from_company abstractmethod

get_account_from_company(company_id)

...

Source code in components/gamification/public/dependency.py
@abstractmethod
def get_account_from_company(self, company_id: str) -> str | None:
    """..."""

get_account_name abstractmethod

get_account_name(account_id)

...

Source code in components/gamification/public/dependency.py
@abstractmethod
def get_account_name(self, account_id: str) -> str | None:
    """..."""

get_first_and_last_names_from_user_ids abstractmethod

get_first_and_last_names_from_user_ids(app_user_ids)

...

Source code in components/gamification/public/dependency.py
@abstractmethod
def get_first_and_last_names_from_user_ids(
    self,
    app_user_ids: list[str],
) -> list[tuple[str, str | None, str | None]]:
    """..."""

get_player_ids_to_cleanup abstractmethod

get_player_ids_to_cleanup()

...

Source code in components/gamification/public/dependency.py
@abstractmethod
def get_player_ids_to_cleanup(self) -> list[UUID]:
    """..."""

get_reference_timezone abstractmethod

get_reference_timezone()

...

Source code in components/gamification/public/dependency.py
@abstractmethod
def get_reference_timezone(self) -> ZoneInfo:
    """..."""

get_user_beneficiaries_cached abstractmethod

get_user_beneficiaries_cached(
    app_user_id, adult_only=False
)

Get beneficiaries of a user's policy with policy information

Source code in components/gamification/public/dependency.py
@abstractmethod
def get_user_beneficiaries_cached(
    self, app_user_id: str, adult_only: bool = False
) -> UserBeneficiariesResult | None:
    """Get beneficiaries of a user's policy with policy information"""

get_user_enrollment abstractmethod

get_user_enrollment(app_user_id)

Return the country code for the current country

Source code in components/gamification/public/dependency.py
@abstractmethod
def get_user_enrollment(
    self, app_user_id: str
) -> GamificationUserEnrollmentData | None:
    """Return the country code for the current country"""

is_feature_enabled_for_user_id abstractmethod

is_feature_enabled_for_user_id(feature_name, app_user_id)

Return True if the feature is enabled for the user, False otherwise

Source code in components/gamification/public/dependency.py
@abstractmethod
def is_feature_enabled_for_user_id(
    self, feature_name: str, app_user_id: str
) -> bool:
    """Return True if the feature is enabled for the user, False otherwise"""

is_user_alaner abstractmethod

is_user_alaner(app_user_id)

...

Source code in components/gamification/public/dependency.py
@abstractmethod
def is_user_alaner(self, app_user_id: str) -> bool:
    """..."""

search_entity_users abstractmethod

search_entity_users(search, account_id, company_id)

...

Source code in components/gamification/public/dependency.py
@abstractmethod
def search_entity_users(
    self, search: str, account_id: str, company_id: str | None
) -> list[tuple[str, str | None, str | None]]:
    """..."""

GamificationUserEnrollmentData dataclass

GamificationUserEnrollmentData(
    account_id,
    account_name,
    company_id,
    company_name,
    user_state,
)

...

account_id instance-attribute

account_id

account_name instance-attribute

account_name

company_id instance-attribute

company_id

company_name instance-attribute

company_name

user_state instance-attribute

user_state

UserBeneficiariesResult dataclass

UserBeneficiariesResult(beneficiaries, policy_id)

Bases: DataClassJsonMixin

Result containing user beneficiaries and policy information.

__post_init__

__post_init__()

Backward compatibility property for user IDs only.

Source code in components/gamification/public/dependency.py
def __post_init__(self):  # type: ignore[no-untyped-def]
    """Backward compatibility property for user IDs only."""
    self.user_ids = [b.user_id for b in self.beneficiaries]

beneficiaries instance-attribute

beneficiaries

policy_id instance-attribute

policy_id

user_ids class-attribute instance-attribute

user_ids = field(init=False)

get_app_dependency

get_app_dependency()

Get the GamificationDependency for the current Flask app

Source code in components/gamification/public/dependency.py
def get_app_dependency() -> GamificationDependency:
    """Get the GamificationDependency for the current Flask app"""
    from flask import current_app

    return cast("CustomFlask", current_app).get_component_dependency(COMPONENT_NAME)  # type: ignore[no-any-return]

set_app_dependency

set_app_dependency(dependency)

Set the GamificationDependency for the current Flask app

Source code in components/gamification/public/dependency.py
def set_app_dependency(dependency: GamificationDependency) -> None:
    """Set the GamificationDependency for the current Flask app"""
    from flask import current_app

    cast("CustomFlask", current_app).add_component_dependency(
        COMPONENT_NAME, dependency
    )

components.gamification.public.enums

achievement_declarations

AchievementDeclarationLimit

Bases: AlanBaseEnum

daily class-attribute instance-attribute
daily = 'daily'
monthly class-attribute instance-attribute
monthly = 'monthly'
no_limit class-attribute instance-attribute
no_limit = 'no_limit'
once class-attribute instance-attribute
once = 'once'
weekly class-attribute instance-attribute
weekly = 'weekly'

achievement_definition

AchievementCode

Bases: AlanBaseEnum

Achievement codes for the gamification system. These codes are used to identify specific achievements that players can earn.

activation_wheel class-attribute instance-attribute
activation_wheel = 'activation_wheel'
baby_sleep_completed class-attribute instance-attribute
baby_sleep_completed = 'baby_sleep_completed'
back_pain_program_exercise class-attribute instance-attribute
back_pain_program_exercise = 'back_pain_program_exercise'
backpain_completed class-attribute instance-attribute
backpain_completed = 'backpain_completed'
berries_gift class-attribute instance-attribute
berries_gift = 'berries_gift'
breathing class-attribute instance-attribute
breathing = 'breathing'
breathing_qvct_zen_d1 class-attribute instance-attribute
breathing_qvct_zen_d1 = 'breathing_qvct_zen_d1'
care_incompatible_device class-attribute instance-attribute
care_incompatible_device = 'care_incompatible_device'
care_reward class-attribute instance-attribute
care_reward = 'care_reward'
debug class-attribute instance-attribute
debug = 'debug'
duel_100 class-attribute instance-attribute
duel_100 = 'duel_100'
duel_1000 class-attribute instance-attribute
duel_1000 = 'duel_1000'
duel_500 class-attribute instance-attribute
duel_500 = 'duel_500'
duel_participation class-attribute instance-attribute
duel_participation = 'duel_participation'
external_activity class-attribute instance-attribute
external_activity = 'external_activity'
health_program_exercise class-attribute instance-attribute
health_program_exercise = 'health_program_exercise'
health_program_qvct_move_d2 class-attribute instance-attribute
health_program_qvct_move_d2 = 'health_program_qvct_move_d2'
health_program_qvct_move_d4 class-attribute instance-attribute
health_program_qvct_move_d4 = 'health_program_qvct_move_d4'
health_program_qvct_zen_d4 class-attribute instance-attribute
health_program_qvct_zen_d4 = 'health_program_qvct_zen_d4'
journaling class-attribute instance-attribute
journaling = 'journaling'
journaling_qvct_zen_d3 class-attribute instance-attribute
journaling_qvct_zen_d3 = 'journaling_qvct_zen_d3'
meditation class-attribute instance-attribute
meditation = 'meditation'
mo_first_discussion class-attribute instance-attribute
mo_first_discussion = 'mo_first_discussion'
perfect_week class-attribute instance-attribute
perfect_week = 'perfect_week'
play_scratch class-attribute instance-attribute
play_scratch = 'play_scratch'
play_scratch_25_1 class-attribute instance-attribute
play_scratch_25_1 = 'play_scratch_25_1'
play_scratch_25_2 class-attribute instance-attribute
play_scratch_25_2 = 'play_scratch_25_2'
play_scratch_25_3 class-attribute instance-attribute
play_scratch_25_3 = 'play_scratch_25_3'
play_scratch_25_4 class-attribute instance-attribute
play_scratch_25_4 = 'play_scratch_25_4'
play_scratch_tp_card class-attribute instance-attribute
play_scratch_tp_card = 'play_scratch_tp_card'
quiz class-attribute instance-attribute
quiz = 'quiz'
qvct_full_move_week class-attribute instance-attribute
qvct_full_move_week = 'qvct_full_move_week'
qvct_full_zen_week class-attribute instance-attribute
qvct_full_zen_week = 'qvct_full_zen_week'
referral class-attribute instance-attribute
referral = 'referral'
shop_contacts_purchase class-attribute instance-attribute
shop_contacts_purchase = 'shop_contacts_purchase'
shop_eyewear_purchase class-attribute instance-attribute
shop_eyewear_purchase = 'shop_eyewear_purchase'
shop_eyewear_vto_frames_tried class-attribute instance-attribute
shop_eyewear_vto_frames_tried = (
    "shop_eyewear_vto_frames_tried"
)
shop_wellbeing_purchase class-attribute instance-attribute
shop_wellbeing_purchase = 'shop_wellbeing_purchase'
streak_100d class-attribute instance-attribute
streak_100d = 'streak_100d'
streak_150d class-attribute instance-attribute
streak_150d = 'streak_150d'
streak_200d class-attribute instance-attribute
streak_200d = 'streak_200d'
streak_20d class-attribute instance-attribute
streak_20d = 'streak_20d'
streak_250d class-attribute instance-attribute
streak_250d = 'streak_250d'
streak_300d class-attribute instance-attribute
streak_300d = 'streak_300d'
streak_365d class-attribute instance-attribute
streak_365d = 'streak_365d'
streak_500d class-attribute instance-attribute
streak_500d = 'streak_500d'
streak_50d class-attribute instance-attribute
streak_50d = 'streak_50d'
streak_7d class-attribute instance-attribute
streak_7d = 'streak_7d'
stress_program_completed class-attribute instance-attribute
stress_program_completed = 'stress_program_completed'
stress_program_exercise class-attribute instance-attribute
stress_program_exercise = 'stress_program_exercise'
team_challenge_1st class-attribute instance-attribute
team_challenge_1st = 'team_challenge_1st'
team_challenge_2nd class-attribute instance-attribute
team_challenge_2nd = 'team_challenge_2nd'
team_challenge_3rd class-attribute instance-attribute
team_challenge_3rd = 'team_challenge_3rd'
teleconsultation_attended class-attribute instance-attribute
teleconsultation_attended = 'teleconsultation_attended'
tp_card_added_to_wallet class-attribute instance-attribute
tp_card_added_to_wallet = 'tp_card_added_to_wallet'
walk_30min_milestone class-attribute instance-attribute
walk_30min_milestone = 'walk_30min_milestone'
walk_5min_milestone class-attribute instance-attribute
walk_5min_milestone = 'walk_5min_milestone'
walk_brisk_10min_milestone1 class-attribute instance-attribute
walk_brisk_10min_milestone1 = 'walk_brisk_10min_milestone1'
walk_brisk_10min_milestone2 class-attribute instance-attribute
walk_brisk_10min_milestone2 = 'walk_brisk_10min_milestone2'
walk_brisk_10min_milestone3 class-attribute instance-attribute
walk_brisk_10min_milestone3 = 'walk_brisk_10min_milestone3'
walk_brisk_20min_milestone1 class-attribute instance-attribute
walk_brisk_20min_milestone1 = 'walk_brisk_milestone1'
walk_brisk_20min_milestone2 class-attribute instance-attribute
walk_brisk_20min_milestone2 = 'walk_brisk_milestone2'
walk_brisk_20min_milestone3 class-attribute instance-attribute
walk_brisk_20min_milestone3 = 'walk_brisk_milestone3'
walk_day_10000 class-attribute instance-attribute
walk_day_10000 = 'walk_day_10000'
walk_day_2500 class-attribute instance-attribute
walk_day_2500 = 'walk_day_2500'
walk_day_5000 class-attribute instance-attribute
walk_day_5000 = 'walk_day_5000'
walk_day_7500 class-attribute instance-attribute
walk_day_7500 = 'walk_day_7500'
walk_qvct_move_d1 class-attribute instance-attribute
walk_qvct_move_d1 = 'walk_qvct_move_d1'
walk_qvct_move_d3 class-attribute instance-attribute
walk_qvct_move_d3 = 'walk_qvct_move_d3'
walk_qvct_move_d5 class-attribute instance-attribute
walk_qvct_move_d5 = 'walk_qvct_move_d5'
walk_qvct_zen_d2 class-attribute instance-attribute
walk_qvct_zen_d2 = 'walk_qvct_zen_d2'
walk_qvct_zen_d5 class-attribute instance-attribute
walk_qvct_zen_d5 = 'walk_qvct_zen_d5'
walking_challenge_referral_use class-attribute instance-attribute
walking_challenge_referral_use = (
    "walking_challenge_referral_use"
)
walking_challenge_referral_used class-attribute instance-attribute
walking_challenge_referral_used = (
    "walking_challenge_referral_used"
)
walking_challenge_started class-attribute instance-attribute
walking_challenge_started = 'walking_challenge_started'
walking_challenge_top_10_percent class-attribute instance-attribute
walking_challenge_top_10_percent = (
    "walking_challenge_top_10_percent"
)
walking_challenge_top_5_percent class-attribute instance-attribute
walking_challenge_top_5_percent = (
    "walking_challenge_top_5_percent"
)
walking_challenge_week_1st class-attribute instance-attribute
walking_challenge_week_1st = 'walking_challenge_week_1st'
walking_challenge_week_2nd class-attribute instance-attribute
walking_challenge_week_2nd = 'walking_challenge_week_2nd'
walking_challenge_week_3rd class-attribute instance-attribute
walking_challenge_week_3rd = 'walking_challenge_week_3rd'

leagues

PlayerStatus

Bases: AlanBaseEnum

Enum representing the status of a player in a league.

INVITED class-attribute instance-attribute
INVITED = 'invited'
MEMBER class-attribute instance-attribute
MEMBER = 'member'

players

PlayerUserState

Bases: AlanBaseEnum

admin class-attribute instance-attribute
admin = 'admin'
beneficiary class-attribute instance-attribute
beneficiary = 'beneficiary'
employee class-attribute instance-attribute
employee = 'employee'
exempted class-attribute instance-attribute
exempted = 'exempted'
individual class-attribute instance-attribute
individual = 'individual'

voucher

VoucherCode

Bases: AlanBaseEnum

Voucher codes for play and shop vouchers.

voucher10 class-attribute instance-attribute
voucher10 = 'voucher10'
voucher150 class-attribute instance-attribute
voucher150 = 'voucher150'
voucher25 class-attribute instance-attribute
voucher25 = 'voucher25'
voucher442 class-attribute instance-attribute
voucher442 = 'voucher442'
voucher5 class-attribute instance-attribute
voucher5 = 'voucher5'
voucher8 class-attribute instance-attribute
voucher8 = 'voucher8'
voucher956 class-attribute instance-attribute
voucher956 = 'voucher956'

VoucherDataAmount module-attribute

VoucherDataAmount = Union[int, float]

components.gamification.public.helpers

data_loader

We need static data to exist in the local database for the component to work properly.

load_gamification_store_base_data

load_gamification_store_base_data(commit=True)
Source code in components/gamification/public/helpers/data_loader.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
def load_gamification_store_base_data(commit: bool = True) -> None:  # noqa: D103
    from components.gamification.internal.models.store_item import StoreItem

    STORE_ITEMS = (
        (
            "5d25cb86-c4fa-46ee-95f2-86651f61aca2",
            "debug",
            "misc",
            1000,
        ),
        (
            "e41f3b6d-2105-4318-b9a6-9c8c2fcbfc9e",
            "gustave_roussy2",
            "charity",
            100,
        ),
        (
            "7c5489b5-c5a9-471d-9a29-03c015bc2f34",
            "avatar_booker",
            "avatar",
            1000,
        ),
        (
            "77ba52c8-4eca-48f0-8c6f-ab71fae9f468",
            "avatar_nova",
            "avatar",
            2000,
        ),
        (
            "ed617f8a-5b1a-4e68-ab60-17d554ac31a0",
            "avatar_blaze",
            "avatar",
            5000,
        ),
        (
            "dfcab7e4-064d-4adb-ae87-ca5842fd2d75",
            "avatar_skye",
            "avatar",
            500,
        ),
        (
            "6f4f4440-15a3-4623-bfe0-1092990ddd6e",
            "avatar_ignatius",
            "avatar",
            0,
        ),
        (
            "0c38d3e8-01a6-4260-bd39-0bc3137d0b05",
            "avatar_nereus",
            "avatar",
            250,
        ),
        (
            "d38c9096-6347-4ac9-a1d5-aa54b497c2bc",
            "avatar_terra",
            "avatar",
            0,
        ),
        (
            "f3c2b1ba-a68f-4a8e-a162-867d00b23ec8",
            "avatar_aqua",
            "avatar",
            250,
        ),
        (
            "5b97bad3-2255-4296-be2a-7c3f4f24ebb8",
            "avatar_alanventura",
            "avatar",
            1000,
        ),
        (
            "249adca0-0ede-4d4b-a00b-674f05075bf1",
            "avatar_hazel",
            "avatar",
            1000,
        ),
        (
            "f00837ac-4bbb-4542-82bd-5795d1dd5c3c",
            "avatar_cristal",
            "avatar",
            0,
        ),
        (
            "42c42366-402b-4f78-ac72-5020c7a80dbb",
            "avatar_ambre",
            "avatar",
            500,
        ),
        (
            "9d544dc2-39b3-496d-87b7-61090d33abad",
            "avatar_spike",
            "avatar",
            500,
        ),
        (
            "6e781b57-c39a-4970-99e2-86d03db506e2",
            "avatar_indianalan",
            "avatar",
            2000,
        ),
        (
            "7ab799a3-64dc-43a3-89ae-c053d8e83644",
            "avatar_skipper",
            "avatar",
            250,
        ),
        (
            "aa4253b5-d7b8-4af4-bd72-57559d01fd7b",
            "avatar_nadalan",
            "avatar",
            1000,
        ),
        (
            "27d27023-eebf-49de-b925-41e1bad49e3d",
            "avatar_scout",
            "avatar",
            500,
        ),
        (
            "f905a263-a3a2-40be-9dd3-e00d0d81a46a",
            "avatar_fleur",
            "avatar",
            250,
        ),
        (
            "aaec0582-f0c1-4783-aa12-aff7d3b5995b",
            "against_malaria_foundation",
            "charity",
            128,
        ),
        (
            "94f0732e-dfa0-4aa4-a761-43de36c1c95f",
            "gustave_roussy",
            "charity",
            100,
        ),
        (
            "9713ecaf-be2e-4d62-92f4-087ea96a3d3c",
            "avatar_amira",
            "avatar",
            1000,
        ),
        (
            "3f5fb7c8-1ab8-4685-801d-04453f8e7bea",
            "avatar_luminos",
            "avatar",
            0,
        ),
        (
            "5547a337-af18-44e8-b403-743e670b4109",
            "avatar_monarch",
            "avatar",
            2000,
        ),
        (
            "19cab99c-86b8-480d-ae24-2fde8932db04",
            "avatar_westlan",
            "avatar",
            500,
        ),
        (
            "378c78d5-b2db-44b6-afa0-9251b66e2282",
            "avatar_peanut",
            "avatar",
            500,
        ),
        (
            "e862ba38-bf1b-4d78-8f44-e50d46af2429",
            "avatar_batlan",
            "avatar",
            5000,
        ),
        (
            "f2ac1455-488a-4a3a-9cd0-c09871b67dbe",
            "avatar_ninjalan",
            "avatar",
            2000,
        ),
        (
            "d4099764-39cb-469c-97c2-79df0950ab36",
            "avatar_mc_alan",
            "avatar",
            2000,
        ),
        (
            "e25adc4c-846b-43fa-9df1-53e7ea57a4d3",
            "avatar_melody",
            "avatar",
            250,
        ),
        (
            "51fc8b23-74b7-49d0-8913-721d915c9423",
            "avatar_monsieur",
            "avatar",
            2000,
        ),
        (
            "e3502714-f3d8-40af-9d68-a6549ecdc367",
            "avatar_starwalker",
            "avatar",
            5000,
        ),
        (
            "a130d173-0a83-43b5-99b6-09fa9e706d87",
            "avatar_scarlet",
            "avatar",
            1000,
        ),
        (
            "3358991b-90cf-4390-a93d-19ec5d00ce80",
            "avatar_jade",
            "avatar",
            250,
        ),
        (
            "314b343c-a3e8-422f-ac7f-006208c37bab",
            "avatar_altruist",
            "avatar",
            250,
        ),
        (
            "7c680857-c2fa-47e8-b90e-19e931cf9209",
            "avatar_chillax",
            "avatar",
            500,
        ),
        (
            "e56e60c6-1347-40b3-b461-9ad8156fbfe3",
            "avatar_magus",
            "avatar",
            10000,
        ),
    )

    ACHIEVEMENT_DEFINITIONS = (
        (
            "d805fb78-8f7e-4264-85e8-b604742907de",
            "walk",
            "walk_brisk_10min_milestone3",
            50,
            "daily",
        ),
        (
            "4f937f73-6b74-4989-b706-0e5ced90e2cf",
            "walk",
            "walk_brisk_10min_milestone2",
            25,
            "daily",
        ),
        (
            "a3a72296-7667-48af-9cec-3266d3d3345f",
            "walk",
            "walk_brisk_10min_milestone1",
            10,
            "daily",
        ),
        (
            "6490d1a6-3b29-442b-a0c9-aec877579c91",
            "walk",
            "walk_brisk_milestone3",
            100,
            "daily",
        ),
        (
            "6bcbebd4-bb64-4254-a847-3399b431edb1",
            "walk",
            "walk_brisk_milestone2",
            50,
            "daily",
        ),
        (
            "94854b5a-109d-4549-bd02-0d2c8ea28b0a",
            "walk",
            "walk_brisk_milestone1",
            25,
            "daily",
        ),
        (
            "a911c8ab-1ef9-4c91-b702-d653d86501ef",
            "walk",
            "walk_world_tour",
            500,
            "once",
        ),
        (
            "67cf91b5-d808-4b35-b297-6f7f571697ac",
            "walk",
            "walk_day_10000",
            25,
            "daily",
        ),
        (
            "fbc47bf5-15fc-4f7b-a544-520b6972380a",
            "walk",
            "walk_day_5000",
            25,
            "daily",
        ),
        (
            "d21149c1-31de-4067-8f95-75f367c537c5",
            "walk",
            "walk_day_2500",
            25,
            "daily",
        ),
        (
            "dc7e6c1e-a9e5-481e-a0b5-086c1f556587",
            "walk",
            "walk_helper",
            500,
            "once",
        ),
        ("479312fd-ff87-45aa-85d9-8324ba1af9ca", "", "debug", 1000, "no_limit"),
        (
            "7eed5277-6183-47b3-8f60-e895408ff06a",
            "walk",
            "walking_challenge_daily14k",
            20,
            "daily",
        ),
        (
            "564e2654-d335-4b69-99d8-986d96ecbbd7",
            "walk",
            "walking_challenge_daily12k",
            20,
            "daily",
        ),
        (
            "88879826-0c29-40d0-8362-d68e3aa5de1b",
            "walk",
            "walking_challenge_referral_used",
            500,
            "no_limit",
        ),
        (
            "4e26dd33-fb9a-4222-a2d4-5ca194773c9e",
            "walk",
            "walking_challenge_referral_use",
            0,
            "once",
        ),
        (
            "ce46d386-5054-4482-871a-68ec305e53f5",
            "walk",
            "walking_challenge_week_3rd",
            100,
            "weekly",
        ),
        (
            "f0ff37cf-2a2c-47dc-be18-82d0c7d5d98b",
            "walk",
            "walking_challenge_week_2nd",
            500,
            "weekly",
        ),
        (
            "1d688775-1f87-4c72-9403-01ef2a50b655",
            "walk",
            "walking_challenge_week_1st",
            1000,
            "weekly",
        ),
        (
            "6d5c1dc9-7d12-47af-b00a-8b6f086596c9",
            "walk",
            "walking_challenge_daily10k",
            20,
            "daily",
        ),
        (
            "c3f28d67-fc37-466f-98c2-a110c321a23e",
            "walk",
            "walking_challenge_daily8k",
            20,
            "daily",
        ),
        (
            "d3046e23-bd91-4e99-aabb-fc09b0494058",
            "walk",
            "walking_challenge_daily6k",
            20,
            "daily",
        ),
        (
            "4fa07f0b-6820-45bd-b3ac-58b31ad526dd",
            "walk",
            "walking_challenge_daily4k",
            20,
            "daily",
        ),
        (
            "a39176b6-6ca2-49a4-ae33-d52d3402c892",
            "walk",
            "walking_challenge_daily2k",
            20,
            "daily",
        ),
        (
            "df2994fc-d509-435b-b84f-291a76d28264",
            "walk",
            "walking_challenge_started",
            100,
            "once",
        ),
        (
            "1104b4dd-5dca-4da5-8f93-d2af3e8eb3a9",
            "program",
            "baby_sleep_completed",
            100,
            "once",
        ),
        (
            "2c8a745e-3fe5-49aa-9507-f8fb0e5dabd5",
            "program",
            "backpain_completed",
            100,
            "once",
        ),
        (
            "a2ef539b-e67f-4049-8bba-888139186e5d",
            "care",
            "care_reward",
            25,
            "no_limit",
        ),
        (
            "e8b805ac-8491-4833-8795-9fd0ddfa240f",
            "care",
            "care_incompatible_device",
            100,
            "once",
        ),
        (
            "0f303a50-cf37-4d8e-a73a-dcd7369b1dfe",
            "mo",
            "mo_first_discussion",
            50,
            "once",
        ),
        (
            "f47ac10b-58cc-4372-a567-0e02b2c3d479",
            "quiz",
            "quiz",
            0,  # Points will be calculated dynamically based on correct answers
            "daily",
        ),
        (
            "d81dc10d-de37-4b9a-afa5-3c664ed7a029",
            "play",
            "team_challenge_1st",
            1000,
            "monthly",
        ),
        (
            "0197b629-6d81-4d25-9db9-59391611ec5a",
            "play",
            "team_challenge_2nd",
            500,
            "monthly",
        ),
        (
            "90719d1a-cf81-4d8e-b596-9c10b927f39b",
            "play",
            "team_challenge_3rd",
            200,
            "monthly",
        ),
    )

    current_session.add_all(
        [
            StoreItem(
                code=code,
                id=UUID(store_item_id),
                points=points,
                category=category,
            )
            for store_item_id, code, category, points in STORE_ITEMS
        ],
    )
    current_session.add_all(
        [
            AchievementDefinition(
                id=UUID(achievement_definition_id),
                category=category,
                code=code,
                points=points,
                declaration_limit=AchievementDeclarationLimit(declaration_limit),
            )
            for achievement_definition_id, category, code, points, declaration_limit in ACHIEVEMENT_DEFINITIONS
        ]
    )

    if commit:
        current_session.commit()
    else:
        current_session.flush()

player_authorisation

get_other_player_context_if_authorised

get_other_player_context_if_authorised(
    current_player_context, other_user_id
)
Source code in components/gamification/public/helpers/player_authorisation.py
def get_other_player_context_if_authorised(  # noqa: D103
    current_player_context: PlayerContextData, other_user_id: str | None
) -> PlayerContextData | None:
    from components.gamification.internal.business_logic.queries.player import (
        get_player_context_from_user_id_cached,
    )

    if not other_user_id or other_user_id == current_player_context.app_user_id:
        return current_player_context

    other_player_context = get_player_context_from_user_id_cached(
        app_user_id=other_user_id
    )
    if not other_player_context:
        return None

    if other_player_context.account_id != current_player_context.account_id:
        current_logger.warning(
            "Requested user is not in the same account", other_user_id=other_user_id
        )
        abort(
            make_json_response(
                code=400,
                data={"error": "Requested user is not in the same account"},
            )
        )
    return other_player_context

get_other_player_id_if_authorised

get_other_player_id_if_authorised(
    current_account_id,
    current_player_id,
    current_user_id,
    other_user_id,
)
Source code in components/gamification/public/helpers/player_authorisation.py
def get_other_player_id_if_authorised(  # noqa: D103
    current_account_id: str | None,
    current_player_id: UUID,
    current_user_id: str,
    other_user_id: str | None,
) -> UUID:
    from components.gamification.internal.models.player import Player

    if not other_user_id or other_user_id == current_user_id:
        return current_player_id

    res: tuple[UUID, str | None] = (
        current_session.query(Player.id, Player.account_id)  # type: ignore[assignment] # noqa: ALN085
        .filter_by(app_user_id=other_user_id)
        .one()
    )
    current_session.commit()

    other_player_id, other_player_account_id = res
    if current_account_id != other_player_account_id:
        current_logger.warning(
            "Requested user is not in the same account", other_user_id=other_user_id
        )
        abort(
            make_json_response(
                code=400,
                data={"error": "Requested user is not in the same account"},
            )
        )
    return other_player_id

shop_rewards

compute_shop_berries_reward_for_product_cost

compute_shop_berries_reward_for_product_cost(product_cost)

Compute the berries reward for a product cost in the shop. The reward is calculated as 1% of the product cost, multiplied by 30. Make sure to keep in sync with computeShopBerriesRewardForProductCost

Source code in components/gamification/public/helpers/shop_rewards.py
1
2
3
4
5
6
7
def compute_shop_berries_reward_for_product_cost(product_cost: float) -> int:
    """
    Compute the berries reward for a product cost in the shop.
    The reward is calculated as 1% of the product cost, multiplied by 30.
    Make sure to keep in sync with computeShopBerriesRewardForProductCost
    """
    return int((1 / 100) * product_cost * 30)

strings

normalize_text

normalize_text(text)

Normalize player text search

Source code in components/gamification/public/helpers/strings.py
def normalize_text(text: str) -> str:
    """Normalize player text search"""
    # Normalize and remove diacritics
    text = unicodedata.normalize("NFD", text)
    text = "".join(c for c in text if unicodedata.category(c) != "Mn")
    # Convert to lower case
    return text.lower()

components.gamification.public.services

achievements

declare_achievement_for_app_user

declare_achievement_for_app_user(
    app_user_id,
    achievement_code,
    berries=None,
    collected=False,
    commit=True,
)

Declare an achievement for a player identified by their app user ID.

Source code in components/gamification/public/services/achievements.py
def declare_achievement_for_app_user(
    app_user_id: str,
    achievement_code: AchievementCode,
    berries: int | None = None,
    collected: bool = False,
    commit: bool = True,
) -> None:
    """
    Declare an achievement for a player identified by their app user ID.
    """
    player_id = get_player_id_from_app_user_id(app_user_id=app_user_id)
    if not player_id:
        current_logger.warning(
            "Player not found while trying to declare achievement",
            user_id=app_user_id,
            achievement_code=achievement_code,
        )
        return

    create_achievements(
        player_id=player_id,
        items=[
            AchievementCreationPayload(
                code=achievement_code,
                day=date.today(),
                berries=berries,
            )
        ],
        collected=collected,
        commit=commit,
    )

list_achievements_for_app_user

list_achievements_for_app_user(app_user_id)

List all achievements for a player identified by their app user ID.

Source code in components/gamification/public/services/achievements.py
def list_achievements_for_app_user(
    app_user_id: str,
) -> list[AchievementData]:
    """
    List all achievements for a player identified by their app user ID.
    """
    player_id = get_player_id_from_app_user_id(app_user_id=app_user_id)
    return get_achievements(player_id=player_id).achievements if player_id else []

player

remove_player_data

remove_player_data(app_user_id, dry_run=True)

Removes all gamification info linked to a player Connects purchases to a fake player Reset player object fields

Source code in components/gamification/public/services/player.py
def remove_player_data(app_user_id: str, dry_run: bool = True) -> None:
    """
    Removes all gamification info linked to a player
    Connects purchases to a fake player
    Reset player object fields
    """
    from components.gamification.internal.business_logic.actions.achievements import (
        delete_achievements_for_player,
    )
    from components.gamification.internal.business_logic.actions.daily_challenge import (
        delete_challenges_for_player,
    )
    from components.gamification.internal.business_logic.actions.duels import (
        delete_duels_for_player,
    )
    from components.gamification.internal.business_logic.actions.player import (
        maybe_create_fake_player,
        reset_player,
    )
    from components.gamification.internal.business_logic.actions.purchases import (
        remove_purchases_for_player,
    )
    from components.gamification.internal.business_logic.actions.reactions import (
        delete_reactions_for_player,
    )
    from components.gamification.internal.business_logic.actions.team_challenge import (
        delete_challenge_boosts_for_player,
        delete_challenge_invitations_for_player,
        delete_challenge_teams_for_player,
    )
    from components.gamification.internal.business_logic.actions.walk_history import (
        delete_steps_for_player,
    )
    from components.gamification.internal.business_logic.actions.walk_leagues import (
        remove_player_from_leagues,
    )
    from components.gamification.internal.business_logic.queries.player import (
        get_player_id_from_app_user_id,
    )

    maybe_create_fake_player(dry_run)

    player_id = get_player_id_from_app_user_id(app_user_id)  # Ensure player exists
    if player_id is None:
        current_logger.error("Player not found for cleanup", app_user_id=app_user_id)
        return

    reset_player(player_id, dry_run)
    delete_reactions_for_player(player_id, dry_run)
    delete_duels_for_player(player_id, dry_run)
    delete_steps_for_player(player_id, dry_run)
    delete_challenges_for_player(player_id, dry_run)
    delete_achievements_for_player(player_id, dry_run)
    remove_player_from_leagues(player_id, dry_run)
    remove_purchases_for_player(player_id, dry_run)
    delete_challenge_boosts_for_player(player_id, dry_run)
    delete_challenge_invitations_for_player(player_id, dry_run)
    delete_challenge_teams_for_player(player_id, dry_run)

vouchers

create_onboarding_cookie_voucher(feature_user)

Create an onboarding cookie voucher for the given user.

Source code in components/gamification/public/services/vouchers.py
def create_onboarding_cookie_voucher(feature_user: FeatureUser) -> None:
    """
    Create an onboarding cookie voucher for the given user.
    """
    from components.gamification.internal.business_logic.actions.vouchers import (
        create_shop_onboarding_cookie_voucher,
    )

    if not get_onboarding_cookie_voucher_code_available():
        raise Exception("No onboarding cookie vouchers available")

    other_voucher = has_onboarding_cookie_voucher(feature_user)
    if other_voucher:
        current_logger.error("Alaner already unlocked shop onboarding voucher")
        raise Exception("Alaner already unlocked shop onboarding voucher")

    create_shop_onboarding_cookie_voucher(app_user_id=feature_user.app_user_id)
get_onboarding_cookie_voucher_code_available()

Check if onboarding cookie vouchers are still available. Returns the voucher code if we haven't reached the limit of 200 vouchers (100 of each type). Returns None if we have reached the limit.

Source code in components/gamification/public/services/vouchers.py
def get_onboarding_cookie_voucher_code_available() -> VoucherCode | None:
    """
    Check if onboarding cookie vouchers are still available.
    Returns the voucher code if we haven't reached the limit of 200 vouchers (100 of each type).
    Returns None if we have reached the limit.
    """
    from components.gamification.internal.helpers.vouchers import (
        SHOP_ONBOARDING_442_COOKIE_VOUCHER_PREFIX,
        SHOP_ONBOARDING_956_COOKIE_VOUCHER_PREFIX,
    )
    from components.gamification.internal.models.voucher import Voucher

    amount_of_442_vouchers = (
        current_session.query(Voucher)  # noqa: ALN085
        .filter(
            Voucher.discount_code.like(f"{SHOP_ONBOARDING_442_COOKIE_VOUCHER_PREFIX}%")
        )
        .count()
    )
    amount_of_956_vouchers = (
        current_session.query(Voucher)  # noqa: ALN085
        .filter(
            Voucher.discount_code.like(f"{SHOP_ONBOARDING_956_COOKIE_VOUCHER_PREFIX}%")
        )
        .count()
    )

    if (amount_of_442_vouchers + amount_of_956_vouchers) >= 200:
        return None

    return (
        VoucherCode.voucher442
        if amount_of_442_vouchers < 100
        else VoucherCode.voucher956
    )
has_onboarding_cookie_voucher(feature_user)

Check if the user has an onboarding cookie voucher.

Source code in components/gamification/public/services/vouchers.py
def has_onboarding_cookie_voucher(feature_user: FeatureUser) -> bool:
    """
    Check if the user has an onboarding cookie voucher.
    """
    from sqlalchemy import or_

    from components.gamification.internal.helpers.vouchers import (
        SHOP_ONBOARDING_442_COOKIE_VOUCHER_PREFIX,
        SHOP_ONBOARDING_956_COOKIE_VOUCHER_PREFIX,
    )
    from components.gamification.internal.models.player import Player
    from components.gamification.internal.models.voucher import Voucher

    player = (
        current_session.query(Player)  # noqa: ALN085
        .filter(Player.app_user_id == feature_user.app_user_id)
        .filter(Player.app_id == feature_user.app_id)
        .one_or_none()
    )

    if not player:
        current_logger.error(
            "Player not found for user",
            feature_user=feature_user,
        )
        return False

    return (
        current_session.query(Voucher)  # noqa: ALN085
        .filter(
            Voucher.player_id == player.id,
            or_(
                Voucher.discount_code.like(
                    f"{SHOP_ONBOARDING_956_COOKIE_VOUCHER_PREFIX}%"
                ),
                Voucher.discount_code.like(
                    f"{SHOP_ONBOARDING_442_COOKIE_VOUCHER_PREFIX}%"
                ),
            ),
        )
        .one_or_none()
        is not None
    )