File: //home/arjun/projects/buyercall_forms/buyercall/buyercall/blueprints/widgets/rest_api.py
import itertools
from uuid import uuid4
import logging as log
import traceback
import sys
import redis
import xml.etree.ElementTree as et
from flask import (
request, jsonify, make_response, Blueprint, current_app
)
from flask_cors import cross_origin
from flask_login import current_user, login_required
from sqlalchemy import and_, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import load_only
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.sql import text
from sqlalchemy.sql.expression import func, case
from buyercall.extensions import db, csrf
from buyercall.lib.util_twilio import subaccount_client
from buyercall.lib.util_bandwidth import bw_client
from buyercall.lib.util_rest import rest_method, rest_partnership_account
from .models import Widget, AgentAssignment, User
from .routing import (
BandwidthRouting,
Routing,
add_widget_lead,
phonecall_inprogress,
NoAgentsException,
after_call_events,
)
from buyercall.blueprints.billing.decorators import subscription_required
from buyercall.blueprints.phonenumbers.models import Phone
from buyercall.blueprints.leads.models import Lead
from buyercall.blueprints.agents.models import Agent
from buyercall.blueprints.billing.models.subscription import Subscription
widgets_api = Blueprint('widgets_api', __name__, template_folder='templates')
DAYS = 86400 # The length of a day in seconds
def json_error(message, error_code=500):
""" Build an Ajax response with the given message and code. """
return make_response(jsonify(error=message), error_code)
@widgets_api.route('/api/outbound', methods=['GET'])
@login_required
def get_widgets():
"""
Retrieves all widgets for the current user.
"""
search = request.args.get('search[value]', '')
order = int(request.args.get('order[0][column]', '-1'))
direction = request.args.get('order[0][dir]', 'asc')
offset = int(request.args.get('start', 0))
limit = int(request.args.get('length', 99))
if limit == -1:
limit = 99
# Check if being viewed by super partner
partnership_account_id = current_user.partnership_account_id
if current_user.is_viewing_partnership:
partnership_account_id = current_user.get_user_viewing_partnership_account_id
columns = [
'id', 'name', 'lead_count', 'created_on', 'updated_on', 'enabled'
]
total = Widget.query.options(
load_only('id', 'name', 'created_on', 'updated_on', 'enabled')
).filter(
Widget.partnership_account_id == partnership_account_id
)
leads = Lead.query.filter(
Lead.partnership_account_id == partnership_account_id
).with_entities(
Lead.widget_guid,
func.sum(1).label('lead_count')
).group_by(Lead.widget_guid).subquery()
filtered = total
if search:
pattern = '%{}%'.format(search)
filtered = total.filter(Widget.name.ilike(pattern))
filtered = filtered.outerjoin(
(leads, Widget.guid == leads.c.widget_guid)
).with_entities(
Widget.id,
Widget.name,
case(
[(leads.c.lead_count.is_(None), 0)],
else_=leads.c.lead_count
).label('lead_count'),
Widget.created_on,
Widget.updated_on,
Widget.enabled
)
sorted_ = filtered
if order in range(len(columns)):
order_pred = '{} {}'.format(columns[order], direction)
sorted_ = sorted_.order_by(text(order_pred))
sorted_ = sorted_.offset(offset).limit(limit)
data = [
{i: row[i] for i in range(len(row))} for row in sorted_.all()
]
subscription = current_user.subscription
for row in data:
if subscription:
row[6] = bool(subscription.usage_over_limit)
else:
row[6] = False
return jsonify(
draw=request.args['draw'],
recordsFiltered=filtered.count(),
recordsTotal=total.count(),
data=data
)
@widgets_api.route('/api/outbound', methods=['POST'])
@login_required
def add_widget():
"""
Creates a new widget.
Returns the ID of the newly created database row, as JSON.
"""
# If we're saving the widget for the first time, and the name already
# exists, change it.
guid = str(uuid4())
options = request.get_json()
options['name'] = fix_name(options['name'])
options['guid'] = guid
# Check if being viewed by super partner
partnership_account_id = current_user.partnership_account_id
if current_user.is_viewing_partnership:
partnership_account_id = current_user.get_user_viewing_partnership_account_id
user_object = User.query.filter(
User.partnership_account_id == partnership_account_id
).first()
company_before_format = user_object.company.replace(" ", "")
company_after_format = ''.join(e for e in company_before_format if e.isalnum())
widget_count = Widget.query.filter(
Widget.partnership_account_id == partnership_account_id
).count() + 1
if widget_count > 0:
widget_count_string = str(widget_count)
else:
widget_count_string = "0{}".format(widget_count)
widget = Widget(
email="{}{}@inbound.buyercall.com".format(company_after_format, widget_count_string),
partnership_account_id=partnership_account_id,
guid=guid,
name=options['name'],
options=options
)
db.session.add(widget)
phone = Phone.query.filter(
Phone.id == options['fromNumberId'], Phone.partnership_account_id == partnership_account_id
).first()
if phone:
widget.inbound = phone
db.session.commit()
return jsonify(id=widget.id, name=widget.name)
@widgets_api.route('/api/outbound/disable/<int:id_>', methods=['POST'])
@login_required
def disable_widget(id_):
# Check if being viewed by super partner
partnership_account_id = current_user.partnership_account_id
if current_user.is_viewing_partnership:
partnership_account_id = current_user.get_user_viewing_partnership_account_id
widget = Widget.query.options(
load_only('id', 'enabled')
).filter(Widget.id == id_, Widget.partnership_account_id == partnership_account_id).first()
widget.enabled = False
db.session.commit()
return ''
@widgets_api.route('/api/outbound/enable/<int:id_>', methods=['POST'])
@login_required
def enable_widget(id_):
# Check if being viewed by super partner
partnership_account_id = current_user.partnership_account_id
if current_user.is_viewing_partnership:
partnership_account_id = current_user.get_user_viewing_partnership_account_id
widget = Widget.query.options(
load_only('id', 'enabled')
).filter(Widget.id == id_, Widget.partnership_account_id == partnership_account_id).first()
widget.enabled = True
db.session.commit()
return ''
@widgets_api.route('/api/outbound/delete/<int:id_>', methods=['POST', 'DELETE'])
@login_required
def delete_widget(id_):
# Check if being viewed by super partner
partnership_account_id = current_user.partnership_account_id
if current_user.is_viewing_partnership:
partnership_account_id = current_user.get_user_viewing_partnership_account_id
widget = Widget.query.options(
load_only('id')
).filter(Widget.id == id_, Widget.partnership_account_id == partnership_account_id).first()
db.session.delete(widget)
db.session.commit()
return ''
@widgets_api.route('/api/outbound/<int:id>', methods=['GET'])
@login_required
def get_widget(id):
""" Retrieves a single widget with the given ID. """
try:
# Check if being viewed by super partner
partnership_account_id = current_user.partnership_account_id
if current_user.is_viewing_partnership:
partnership_account_id = current_user.get_user_viewing_partnership_account_id
widget = Widget.query.filter(
and_(Widget.id == id, Widget.partnership_account_id == partnership_account_id)
).one()
widget.options['name'] = widget.name
widget.options['guid'] = widget.guid
widget.options['id'] = widget.id
widget.options['fromNumberId'] = widget.inbound_id
return jsonify(widget.options)
except NoResultFound:
return make_response('Widget not found.', 404)
@widgets_api.route('/api/outbound/<int:id>', methods=['PUT'])
@login_required
def change_widget(id):
""" Changes a widget with the given id. """
json = request.get_json()
# Check if being viewed by super partner
partnership_account_id = current_user.partnership_account_id
if current_user.is_viewing_partnership:
partnership_account_id = current_user.get_user_viewing_partnership_account_id
widget = Widget.query.filter(
and_(Widget.id == id, Widget.partnership_account_id == partnership_account_id)
).one()
widget.name = json['name']
widget.options = json
phone = Phone.query.filter(
Phone.id == json['fromNumberId'], Phone.partnership_account_id == partnership_account_id
).first()
if phone:
widget.inbound = phone
else:
widget.inbound, widget.inbound_id = None, None
db.session.commit()
return jsonify(id=widget.id, name=widget.name)
@widgets_api.route('/api/outbound/phonenumbers', methods=['GET'])
@login_required
@subscription_required
def phonenumbers():
""" Retrieves the list of all phone numbers which the user is entitled to use
in his widgets.
"""
# Check if being viewed by super partner
partnership_account_id = current_user.partnership_account_id
if current_user.is_viewing_partnership:
partnership_account_id = current_user.get_user_viewing_partnership_account_id
result = db.session.query(Phone.id, Phone.type, Phone.phonenumber, Phone.friendly_name).filter(
Phone.partnership_account_id == partnership_account_id,
Phone.type.in_(('tracking', 'priority')),
Phone.is_deactivated.is_(False)
).all()
return jsonify(data=[
{"id": n[0], "type": n[1], "number": n[2], "friendly_name": n[3]} for n in result
])
@widgets_api.route('/api/outbound/settings/<guid>', methods=['GET'])
@cross_origin()
def get_settings(guid):
""" Called by the deployed widget to retrieve display settings.
"""
try:
from buyercall.blueprints.partnership.models import PartnershipAccount
widget = Widget.query.join(
Widget.partnership_account
).join(
PartnershipAccount.partnership
).join(
*Widget.agents.attr
).filter(
Widget.guid == guid
).one()
if not widget.enabled:
return 'Widget disabled.', 403
subscription = widget.partnership_account.subscription
if not subscription:
subscription = widget.partnership_account.partnership.subscription
if subscription.usage_over_limit:
log.info('Available minutes exceeded for partnership_account {}'.format(widget.partnership_account_id))
return 'Available minutes exceeded.', 402
# Sanitize widget information
result = dict(widget.options)
result['agentsAvailable'] = widget.agents_available
for key in [
'agents', 'scheduleTimezone', 'days', 'availableFrom',
'availableTo', 'recordCalls', 'retryRouting', 'routeInSequence',
'routeRandomly', 'routeSimultaneously', 'voicemail',
'voicemailMessage', 'whisperMessage'
]:
if key in result:
del result[key]
return jsonify(result)
except NoResultFound:
return make_response(
'Widget not found, or has no agents assigned.', 404)
@widgets_api.route('/api/call', methods=['POST'])
@csrf.exempt
@cross_origin()
def call():
""" The endpoint that gets called when the lead presses 'Talk!' on the
widget.
Receives the widget GUID as a query string parameter, and the user's data
in the JSON body.
"""
from buyercall.blueprints.partnership.models import PartnershipAccount
# Save the lead
guid = request.args.get('guid')
json = request.get_json()
if guid:
widget = Widget.query.outerjoin(
(AgentAssignment, Widget.assignments)
).outerjoin(
(Agent, AgentAssignment.agent)
).join(Widget.partnership_account).join(PartnershipAccount.partnership).filter(Widget.guid == guid).first()
else:
log.error("No guid provided")
return jsonify(success=False)
log.info("The call widget json request is: {}".format(json))
if json is not None:
try:
if widget and 'firstName' in json and json['firstName'] in ['', ' ']\
or 'phoneNumber' in json and json['phoneNumber'] in ['', ' ']:
log.error('No lead fields provided for call widget ' + str(widget.name) + ' - ' + str(widget.guid) + '.')
except:
log.error('No lead fields provided for call widget ' + str(widget.name) + ' - ' + str(widget.guid) + '.')
return jsonify(success=False)
subscription = widget.partnership_account.subscription
if not subscription:
subscription = widget.partnership_account.partnership.subscription
if subscription.usage_over_limit:
log.warning('partnership_account {} has exceeded their quota.'.format(
widget.partnership_account_id
))
return jsonify(success=False)
try:
lead_on_call = phonecall_inprogress(widget, **json)
if lead_on_call:
return jsonify(success=True)
else:
lead = add_widget_lead(widget, **json)
# import partnership information to get partnership id
from buyercall.blueprints.partnership.models import Partnership, PartnershipAccount
partner_account = PartnershipAccount.query \
.filter(PartnershipAccount.id == widget.partnership_account_id).first()
partner = Partnership.query.filter(Partnership.id == partner_account.partnership_id).first()
# Is it a Bandwidth or a Twilio number?
if widget.inbound.type == 'tracking':
client = bw_client(partner.id, 'voice')
log.info('Calling Bandwidth number...')
BandwidthRouting(client).call_lead(widget, lead)
else:
log.info('Calling Twilio number...')
subaccount_sid = subscription.twilio_subaccount_sid
client = subaccount_client(subaccount_sid, partner.id)
Routing(client).call_lead(widget, lead)
except NoAgentsException:
return jsonify(success=False, code='ERR_NO_AGENTS', lead_id=lead.id)
return jsonify(success=True, callId=lead.id)
else:
return jsonify(success=False)
@widgets_api.route('/api/save_lead', methods=['POST'])
@csrf.exempt
@cross_origin()
def save_lead():
""" Saves the lead information in the database.
Receives the widget GUID as a query string parameter, and the user's data in the JSON body.
"""
# Save the lead
guid = request.args.get('guid')
json = request.get_json()
if guid:
widget = Widget.query.filter(Widget.guid == guid).first()
else:
log.error('No guid provided')
return jsonify(success=False)
log.info("The after hours call widget json request is: {}".format(json))
if json is not None:
try:
if widget and 'firstName' in json and json['firstName'] in ['', ' '] or \
'lastName' in json and json['lastName'] in ['', ' '] or \
'phoneNumber' in json and json['phoneNumber'] in ['', ' '] or \
'emailAddress' in json and json['emailAddress'] in ['', ' ']:
log.error('No lead fields provided for widget ' + str(widget.name) + ' - ' + str(widget.guid) + '.')
except:
log.error('No lead fields provided for widget ' + str(widget.name) + ' - ' + str(widget.guid) + '.')
return jsonify(success=False)
lead = add_widget_lead(widget, status='missed', **json)
try:
after_call_events(lead, widget)
# send_notify_email(lead, widget)
except Exception as e:
log.error(traceback.format_exc())
# TODO: Should we schedule retries here?
# schedule_retries(lead)
return jsonify(success=True)
else:
return jsonify(success=False)
@widgets_api.route('/api/call_status/<int:lead_id>', methods=['GET'])
@csrf.exempt
@cross_origin()
def call_status(lead_id):
# Declare redis url
redis_db = redis.StrictRedis(
host=current_app.config['REDIS_CONFIG_URL'],
port=current_app.config['REDIS_CONFIG_PORT'],
decode_responses=True
)
try:
connect = redis_db.get('CONNECT{}'.format(lead_id))
if connect == '1':
return jsonify(callConnect=True)
elif connect == '-1':
return jsonify(callConnect=False, error=True)
except Exception as e:
log.error('Cannot retrieve lead status - is Redis accessible?')
return jsonify(callConnect=False)
def fix_name(old_name):
""" Queries the database for all names starting with `old_name` and ensures
no name clash is taking place.
"""
def name_generator():
yield old_name
for i in itertools.count(2):
yield '{} {}'.format(old_name, i)
# Check if being viewed by super partner
# partnership_account_id = current_user.partnership_account_id
#
# if current_user.is_viewing_partnership:
# partnership_account_id = current_user.get_user_viewing_partnership_account_id
escaped_name = old_name.replace('\\', '\\\\').replace('%', '\\%').replace(
'_', '\\_'
)
same_name_widgets = Widget.query.filter(
and_(Widget.name.like(escaped_name + '%'))
).all()
db_names = set(widget.name for widget in same_name_widgets)
for name in name_generator():
if name not in db_names:
return name
@widgets_api.route('/api/v1/outbound', methods=['GET'])
@rest_method
def get_outbound():
"""
External REST API endpoint.
Retrieves all widgets for the current user.
"""
leads = Lead.query.filter(
Lead.partnership_account_id == rest_partnership_account.id
).with_entities(
Lead.widget_guid,
func.sum(1).label('lead_count')
).group_by(Lead.widget_guid).subquery()
all_outbound = Widget.query.options(
load_only('id', 'name', 'created_on', 'updated_on', 'enabled')
).filter(
Widget.partnership_account_id == rest_partnership_account.id
).outerjoin(
(leads, Widget.guid == leads.c.widget_guid)
).with_entities(
Widget.id,
Widget.name,
case(
[(leads.c.lead_count.is_(None), 0)],
else_=leads.c.lead_count
).label('lead_count'),
Widget.created_on,
Widget.updated_on,
Widget.enabled
).all()
data = []
for o in all_outbound:
data.append(dict(
id=o.id,
friendly_name=o.name,
lead_count=o.lead_count,
created_on=o.created_on,
updated_on=o.updated_on,
enabled=o.enabled
))
response = make_response(jsonify(outbound=data))
response.headers['Cache-Control'] = 'no-cache'
return response
def set_custom_agent_lead_text(lead_id, vehicle_details, vehicle_year, vehicle_make, vehicle_model):
""" Sets a custom message to be used as the whisper message. The custom message will mostly be used for setting
vehicle details. The remaining fields that are set are specifically for the notification mail.
"""
# Declare redis url
redis_db = redis.StrictRedis(host=current_app.config['REDIS_CONFIG_URL'],
port=current_app.config['REDIS_CONFIG_PORT'])
vehicle_details = ' . . Vehicle information is . . . ' + vehicle_details
redis_db.setex('CUSTOM_LEAD_MESSAGE:' + str(lead_id), 2 * DAYS, vehicle_details)
redis_db.setex('CUSTOM_LEAD_YEAR:' + str(lead_id), 2 * DAYS, vehicle_year)
redis_db.setex('CUSTOM_LEAD_MAKE:' + str(lead_id), 2 * DAYS, vehicle_make)
redis_db.setex('CUSTOM_LEAD_MODEL:' + str(lead_id), 2 * DAYS, vehicle_model)
def set_lead_xml(lead_id, email_text):
""" Saves the xml content in memory to be forwarded to CRM if need be.
"""
# Declare redis url
redis_db = redis.StrictRedis(host=current_app.config['REDIS_CONFIG_URL'],
port=current_app.config['REDIS_CONFIG_PORT'])
redis_db.setex('CUSTOM_LEAD_XML:' + str(lead_id), 2 * DAYS, email_text)