from collections import defaultdict
import logging
from twisted.internet import defer
from adpay.db import utils as db_utils
from adpay.stats import consts as stats_consts
#: Filter separator, used in range filters (see protocol or api documentation).
FILTER_SEPARATOR = '--'
[docs]def get_default_event_payment(event_doc, max_cpc, max_cpm):
"""
This is maximum payment. Defined in campaign or, in case of custom events (eg. conversions) in event itself.
:param event_doc: Event document
:param max_cpc: Cost per click
:param max_cpm: Cost per view/impression (CPM)
:return: Payment per event
"""
# Default payment is 0
event_type, event_payment = event_doc['event_type'], 0
# For conversion, use value specified in the event
if event_type == stats_consts.EVENT_TYPE_CONVERSION:
event_payment = event_doc['event_value']
# For clicks, use value specified in campaign
elif event_type == stats_consts.EVENT_TYPE_CLICK:
event_payment = max_cpc
# For views/impressions, use value specified in campaign, divided by 1000 (cost per mille)
elif event_type == stats_consts.EVENT_TYPE_VIEW:
event_payment = max_cpm / 1000
logger = logging.getLogger(__name__)
logger.debug("Event type {0}, default value: {1}".format(event_type, event_payment))
return event_payment
[docs]def filter_event(event_doc, campaign_doc, banner_doc):
"""
Filter out events that don't pass our validation conditions.
See adpay,stats.consts module for more details.
:param event_doc: Event document under consideration.
:param campaign_doc: Campaign document for this event.
:param banner_doc: Banner document for this event.
:return: Reason status for rejection (0 - not rejected). See `adpay.stats.consts`.
"""
logger = logging.getLogger(__name__)
logger.debug(event_doc)
# Accept, but don't pay for this event
if event_doc['event_type'] not in stats_consts.PAID_EVENT_TYPES:
return stats_consts.EVENT_PAYMENT_ACCEPTED
# Reject, because campaign doesn't exist
if campaign_doc is None or campaign_doc.get('removed', False):
logger.warning("Campaign not found: {0}".format(event_doc['campaign_id']))
return stats_consts.EVENT_PAYMENT_REJECTED_CAMPAIGN_NOT_FOUND
# Reject, because banner doesn't exist
if banner_doc is None:
logger.warning("Banner not found: {0}".format(event_doc['banner_id']))
return stats_consts.EVENT_PAYMENT_REJECTED_BANNER_NOT_FOUND
# Reject, because human score is too low
if event_doc['human_score'] <= stats_consts.HUMAN_SCORE_THRESHOLD:
return stats_consts.EVENT_PAYMENT_REJECTED_HUMAN_SCORE_TOO_LOW
# Reject, because event keywords don't pass campaign filters (invalid targeting)
if stats_consts.VALIDATE_CAMPAIGN_FILTERS and not validate_keywords(campaign_doc['filters'],
event_doc['our_keywords']):
return stats_consts.EVENT_PAYMENT_REJECTED_INVALID_TARGETING
# Accept otherwise
return stats_consts.EVENT_PAYMENT_ACCEPTED
[docs]def validate_keywords(filters_dict, keywords):
"""
Validate campaign filters.
:param filters_dict: Required and excluded keywords
:param keywords: Keywords being tested.
:return: True or False
"""
return validate_require_keywords(filters_dict, keywords) and validate_exclude_keywords(filters_dict, keywords)
[docs]def validate_bounds(bounds, keyword_values):
"""
Validate if keyword value is correct.
Value is between bounds (bounds has two elements)
or
Value is equal to bounds (default, bounds is assumed to have one element)
:param bounds: Iterable (1 or 2 elements)
:param keyword_values: Keyword value being tested.
:return: True or False
"""
for kv in keyword_values:
if (len(bounds) == 2 and bounds[0] < kv < bounds[1]) \
or (bounds[0] == kv):
return True
return False
[docs]def validate_require_keywords(filters_dict, keywords):
"""
Validate campaign require filters.
:param filters_dict: Required and excluded keywords
:param keywords: Keywords being tested.
:return: True or False
"""
for category_keyword, ckvs in filters_dict.get('require').items():
if category_keyword not in keywords:
return False
for category_keyword_value in ckvs:
bounds = category_keyword_value.split(FILTER_SEPARATOR)
if validate_bounds(bounds, keywords.get(category_keyword)):
break
else:
return False
return True
[docs]def validate_exclude_keywords(filters_dict, keywords):
"""
Validate campaign exclude filters.
:param filters_dict: Required and excluded keywords
:param keywords: Keywords being tested.
:return: True or False
"""
for category_keyword, ckvs in filters_dict.get('exclude').items():
if category_keyword not in keywords:
continue
for category_keyword_value in ckvs:
bounds = category_keyword_value.split(FILTER_SEPARATOR)
if validate_bounds(bounds, keywords.get(category_keyword)):
return False
return True
[docs]@defer.inlineCallbacks
def update_events_payments(campaign_doc, timestamp, uid, user_budget):
"""
Update or create event payments in the database by dividing user budget among events.
:param campaign_doc: Campaign document
:param timestamp: Timestamp for the time period of calculation
:param uid: User identifier
:param user_budget: User budget
:return:
"""
logger = logging.getLogger(__name__)
# Get all events for chosen campaign within chosen time period for a chosen user
user_events_iter = yield db_utils.get_events_per_user_iter(campaign_doc['campaign_id'], timestamp, uid)
while True:
event_doc = yield user_events_iter.next()
if event_doc is None:
break
event_type = event_doc['event_type']
banner_doc = yield db_utils.get_banner(event_doc['banner_id'])
payment_reason = filter_event(event_doc, campaign_doc, banner_doc)
# Pay when not rejected
if not payment_reason and event_type in stats_consts.PAID_EVENT_TYPES:
event_value = int(user_budget[event_type]['event_value'])
else:
event_value = 0
# Save to database
yield db_utils.update_event_payment(campaign_doc['campaign_id'],
timestamp,
event_doc['event_id'],
event_value,
payment_reason)
yield logger.debug("New payment ({0}, {1}): {2}, {3}. {4}, {5}".format(campaign_doc['campaign_id'],
timestamp,
event_doc['event_id'],
event_type,
event_value,
payment_reason))
[docs]@defer.inlineCallbacks
def create_user_budget(campaign_doc, timestamp, uid):
"""
Calculate individual user budgets.
User budget dictionary default values for each event type are:
{
'default_value': 0.0, # Default value for this event type
'event_value': 0.0, # Calculated event value (used later on)
'num': 0, # Number of events of this time in the time period
'share': 0.0 # Share of this user for this event type in the time period
}
:param campaign_doc: Campaign document
:param timestamp: Timestamp (last hour)
:param uid:
:return: User budget dictionary
"""
logger = logging.getLogger(__name__)
user_budget = defaultdict(lambda: dict(default_value=0,
event_value=0.0,
num=0,
share=0.0))
if campaign_doc is None:
defer.returnValue(user_budget)
user_events_iter = yield db_utils.get_events_per_user_iter(campaign_doc['campaign_id'], timestamp, uid)
while True:
event_doc = yield user_events_iter.next()
if not event_doc:
break
event_type = event_doc['event_type']
banner_doc = yield db_utils.get_banner(event_doc['banner_id'])
if not filter_event(event_doc, campaign_doc, banner_doc) and event_type in stats_consts.PAID_EVENT_TYPES:
user_budget[event_type]['num'] += 1
user_budget[event_type]['default_value'] += get_default_event_payment(event_doc,
campaign_doc['max_cpc'],
campaign_doc['max_cpm'])
else:
logger.warning('Event type for event_id: ' + event_doc['event_id'] + ' not included in payment calculation.')
for event_type in stats_consts.PAID_EVENT_TYPES:
if user_budget[event_type]['num'] > 0:
user_budget[event_type]['default_value'] /= user_budget[event_type]['num']
yield logger.debug(user_budget)
defer.returnValue(user_budget)
[docs]@defer.inlineCallbacks
def delete_campaign(campaign_id):
"""
Delete campaign document and all campaign banners
:param campaign_id:
:return:
"""
logger = logging.getLogger(__name__)
yield db_utils.delete_campaign(campaign_id)
yield db_utils.delete_campaign_banners(campaign_id)
yield logger.info("Removed campaign {0} with banners.".format(campaign_id))