import numpy
import pandas
from django.db import transaction
from ppc_robot_lib import tasks
from ppc_robot_lib.clients.enums import ClientGoalType
from ppc_robot_lib.clients.resolver import ClientAccountGoalResolver, get_resolver_manager
from ppc_robot_lib.clients.types import GoalValue
from ppc_robot_lib import models
from ppc_robot_lib.reporting import exceptions
__all__ = ['join_account_info', 'has_goal', 'get_goal', 'get_budget', 'get_client_id', 'get_billing_period_id']
[docs]
def join_account_info(
table: pandas.DataFrame,
properties=list[str],
client_id_column: str = None,
context_client_id: bool = False,
suffix='_account',
) -> pandas.DataFrame:
"""
Fetches account information and settings and joins them to the given table. The accounts are fetched
either by value in column ``client_id_column``, or by client ID specified in task context. Lookups are
always performed by the ``ext_id`` column in the :py:class:`ppc_robot_lib.models.client_account.ClientAccount`
model.
Properties is either a list of columns to be fetched, any field from
:py:class:`ppc_robot_lib.models.client_account.ClientAccount` can be used. If the column already exits in the table,
specified suffix is appended to the result name (``_account`` by default).
A copy of the given DataFrame with additional details is returned.
:param table: Table name to which the account information should be added.
:param properties: Properties of the :py:class:`ppc_robot_lib.models.client_account.ClientAccount` model which
should be added as columns to the table.
:param client_id_column: Use the specified column values as input for the lookup.
:param context_client_id: Set to ``True`` if you would like to use client_id stored in the task context.
:param suffix: Suffix to add to the columns if they already exists in the table.
:return: DataFrame with new information.
"""
task_ctx = tasks.get_context()
if client_id_column is None and context_client_id is False:
raise ValueError('You must either specify client_id_column, or set context_client_id to True.')
select_columns = properties.copy()
select_columns.append('ext_id')
with transaction.atomic():
user_credentials = task_ctx.user_credentials
service_account_id = task_ctx.target_object.service_account.id
if client_id_column is not None:
ext_ids = table[client_id_column].unique().tolist()
client_account_list = (
models.ClientAccount.objects.filter(service_account_id=service_account_id, ext_id__in=ext_ids)
.only(*select_columns)
.values()
)
# Determine join column type for the conversion.
col_type = table[client_id_column].dtype
if numpy.issubdtype(col_type, numpy.integer):
transform = int
elif numpy.issubdtype(col_type, numpy.floating):
transform = float
else:
transform = str
# Create index from ext_ids.
idx = [transform(row['ext_id']) for row in client_account_list]
# Import results to DataFrame.
client_list_table = pandas.DataFrame(data=list(client_account_list), index=idx, columns=properties)
# Join the account information to the main table.
if not client_list_table.empty and not table.empty:
return pandas.merge(
table,
client_list_table,
how='left',
left_on=client_id_column,
right_index=True,
sort=False,
suffixes=('', suffix),
)
else:
new_table = table.copy(deep=False)
for col in properties:
new_table[col] = None
return new_table
else:
client_id = task_ctx.client_id
if client_id is None:
raise ValueError('Client ID is unknown, cannot fetch account information!')
client_accounts = models.ClientAccount.objects.filter(
user_id=user_credentials.user_id, service_account_id=service_account_id, ext_id=client_id
).only(*select_columns)
client_account: models.ClientAccount = client_accounts.first()
if client_account is None:
raise ValueError(
f'Client account with ID {client_id} for user {user_credentials.user_id} was not found!'
)
col_iter = properties
new_table = table.copy(deep=False)
for column in col_iter:
if column not in new_table:
new_table[column] = getattr(client_account, column)
else:
new_table[column + suffix] = getattr(client_account, column)
return new_table
def has_goal(goal_type: ClientGoalType, allow_alternative: bool = True) -> bool:
"""
Checks if a goal of given type is defined for currently active client account.
Fails if there the task does not run for a client account and there is no active client account.
:param goal_type: Type of the goal.
:param allow_alternative: Can we look for the alternative goal (e.g. PNO <-> ROAS)?
:return:
"""
with transaction.atomic():
task_ctx = tasks.get_context()
resolver = _get_account_resolver(task_ctx)
return resolver.has_goal(goal_type, allow_alternative)
def get_goal(goal_type: ClientGoalType, allow_alternative: bool = True, convert=True) -> GoalValue | None:
"""
Gets a goal of given type defined for currently active client account.
Fails if there the task does not run for a client account and there is no active client account.
:param goal_type: Type of the goal.
:param allow_alternative: Can we look for the alternative goal (e.g. PNO <-> ROAS)?
:param convert: Convert the return value to currently active currency?
:return:
"""
with transaction.atomic():
task_ctx = tasks.get_context()
resolver = _get_account_resolver(task_ctx)
goal_value = resolver.get_goal(goal_type, allow_alternative)
if not goal_value:
return None
if convert and task_ctx.currency:
return goal_value.convert_to(task_ctx.currency)
else:
return goal_value
def get_budget(convert=True) -> GoalValue | None:
"""
Gets a budget defined for currently active client account.
Fails if there the task does not run for a client account and there is no active client account.
:param convert: Convert the return value to currently active currency?
:return: Object with budget amount, ``None`` if no budget is set.
"""
with transaction.atomic():
task_ctx = tasks.get_context()
resolver = _get_account_resolver(task_ctx)
budget = resolver.get_budget()
if not budget:
return None
if convert and task_ctx.currency:
return budget.convert_to(task_ctx.currency)
else:
return budget
def get_client_id() -> int:
"""
:return: ID of the currently active client. If the job is run for a client, the client ID is returned. If the job
is run for a ClientAccount, an ID of client to which the ClientAccount is linked is returned.
:raises exceptions.ClientNotAvailableError: if the job is run for a service account or the ClientAccount is not
linked to any client.
"""
task_ctx = tasks.get_context()
target_object = task_ctx.target_object
if target_object.is_client():
return target_object.client.id
elif target_object.is_client_account():
try:
return target_object.client_account.client_link.client_id
except models.ClientAccountLink.DoesNotExist:
raise exceptions.ClientNotAvailableError(
'Client ID is not available, because the job is run for a ClientAccount that is not linked to a client.'
)
else:
raise exceptions.ClientNotAvailableError(
'Client ID is available only for jobs run at the client level, or for client accounts linked to a client.'
)
def get_billing_period_id() -> int:
task_ctx = tasks.get_context()
user_id = task_ctx.user_credentials.user_id
try:
team = models.Team.objects.get(team_user__user_id=user_id)
return team.active_billing_period.id
except models.Team.DoesNotExist:
raise exceptions.BillingPeriodNotAvailableError(
'Billing Period is not available, because the user is not linked to any team.'
)
except models.TeamBillingPeriod.DoesNotExist:
raise exceptions.BillingPeriodNotAvailableError(
'Billing Period is not available, because the team has no active billing period.'
)
def _get_account_resolver(task_ctx) -> ClientAccountGoalResolver:
if not task_ctx.target_object or not task_ctx.target_object.is_client_account():
raise ValueError('Goals and budgets for accounts can be retrieved only in client account level tasks.')
return get_resolver_manager().get_account_resolver(task_ctx.target_object.client_account.id)