Use Blender ID to obtain subscription status.
Instead of performing a call to the Blender Store, call to Blender ID to get the user's subscription status. Currently this is performed as a second HTTP call after logging in; in the future we may want to include the roles in the login response from Blender ID, so that we can do this in one call instead of two.
This commit is contained in:
parent
8ba4cc5c0c
commit
0b218eb656
@ -1,94 +1,37 @@
|
|||||||
|
import collections
|
||||||
import logging
|
import logging
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from flask import current_app, Blueprint
|
from flask import Blueprint
|
||||||
|
|
||||||
|
from pillar.auth import UserClass
|
||||||
from pillar.api.utils import authorization
|
from pillar.api.utils import authorization
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
blueprint = Blueprint('blender_cloud.subscription', __name__)
|
blueprint = Blueprint('blender_cloud.subscription', __name__)
|
||||||
|
|
||||||
|
# Mapping from roles on Blender ID to roles here in Pillar.
|
||||||
def fetch_subscription_info(email: str) -> typing.Optional[dict]:
|
# Roles not mentioned here will not be synced from Blender ID.
|
||||||
"""Returns the user info dict from the external subscriptions management server.
|
ROLES_BID_TO_PILLAR = {
|
||||||
|
'cloud_subscriber': 'subscriber',
|
||||||
:returns: the store user info, or None if the user can't be found or there
|
'cloud_demo': 'demo',
|
||||||
was an error communicating. A dict like this is returned:
|
'cloud_has_subscription': 'has_subscription',
|
||||||
{
|
}
|
||||||
"shop_id": 700,
|
|
||||||
"cloud_access": 1,
|
|
||||||
"paid_balance": 314.75,
|
|
||||||
"balance_currency": "EUR",
|
|
||||||
"start_date": "2014-08-25 17:05:46",
|
|
||||||
"expiration_date": "2016-08-24 13:38:45",
|
|
||||||
"subscription_status": "wc-active",
|
|
||||||
"expiration_date_approximate": true
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from requests.adapters import HTTPAdapter
|
|
||||||
import requests.exceptions
|
|
||||||
|
|
||||||
external_subscriptions_server = current_app.config['EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER']
|
|
||||||
|
|
||||||
if log.isEnabledFor(logging.DEBUG):
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
log_email = urllib.parse.quote(email)
|
|
||||||
log.debug('Connecting to store at %s?blenderid=%s',
|
|
||||||
external_subscriptions_server, log_email)
|
|
||||||
|
|
||||||
# Retry a few times when contacting the store.
|
|
||||||
s = requests.Session()
|
|
||||||
s.mount(external_subscriptions_server, HTTPAdapter(max_retries=5))
|
|
||||||
|
|
||||||
try:
|
|
||||||
r = s.get(external_subscriptions_server,
|
|
||||||
params={'blenderid': email},
|
|
||||||
verify=current_app.config['TLS_CERT_FILE'],
|
|
||||||
timeout=current_app.config.get('EXTERNAL_SUBSCRIPTIONS_TIMEOUT_SECS', 10))
|
|
||||||
except requests.exceptions.ConnectionError as ex:
|
|
||||||
log.error('Error connecting to %s: %s', external_subscriptions_server, ex)
|
|
||||||
return None
|
|
||||||
except requests.exceptions.Timeout as ex:
|
|
||||||
log.error('Timeout communicating with %s: %s', external_subscriptions_server, ex)
|
|
||||||
return None
|
|
||||||
except requests.exceptions.RequestException as ex:
|
|
||||||
log.error('Some error communicating with %s: %s', external_subscriptions_server, ex)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if r.status_code != 200:
|
|
||||||
log.warning("Error communicating with %s, code=%i, unable to check "
|
|
||||||
"subscription status of user %s",
|
|
||||||
external_subscriptions_server, r.status_code, email)
|
|
||||||
return None
|
|
||||||
|
|
||||||
store_user = r.json()
|
|
||||||
|
|
||||||
if log.isEnabledFor(logging.DEBUG):
|
|
||||||
import json
|
|
||||||
log.debug('Received JSON from store API: %s',
|
|
||||||
json.dumps(store_user, sort_keys=False, indent=4))
|
|
||||||
|
|
||||||
return store_user
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/update-subscription')
|
@blueprint.route('/update-subscription')
|
||||||
@authorization.require_login()
|
@authorization.require_login()
|
||||||
def update_subscription():
|
def update_subscription() -> typing.Tuple[str, int]:
|
||||||
"""Updates the subscription status of the current user.
|
"""Updates the subscription status of the current user.
|
||||||
|
|
||||||
Returns an empty HTTP response.
|
Returns an empty HTTP response.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pprint
|
|
||||||
from pillar import auth
|
from pillar import auth
|
||||||
from pillar.api import blender_id, service
|
from pillar.api import blender_id
|
||||||
from pillar.api.utils import authentication
|
|
||||||
|
|
||||||
my_log: logging.Logger = log.getChild('update_subscription')
|
my_log: logging.Logger = log.getChild('update_subscription')
|
||||||
user_id = authentication.current_user_id()
|
current_user = auth.get_current_user()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bid_user = blender_id.fetch_blenderid_user()
|
bid_user = blender_id.fetch_blenderid_user()
|
||||||
@ -98,46 +41,60 @@ def update_subscription():
|
|||||||
|
|
||||||
if not bid_user:
|
if not bid_user:
|
||||||
my_log.warning('Logged in user %s has no BlenderID account! '
|
my_log.warning('Logged in user %s has no BlenderID account! '
|
||||||
'Unable to update subscription status.', user_id)
|
'Unable to update subscription status.', current_user.user_id)
|
||||||
return '', 204
|
return '', 204
|
||||||
|
|
||||||
# Use the Blender ID email address to check with the store. At least that reduces the
|
do_update_subscription(current_user, bid_user)
|
||||||
# number of email addresses that could be out of sync to two (rather than three when we
|
return '', 204
|
||||||
# use the email address from our local database).
|
|
||||||
|
|
||||||
|
def do_update_subscription(local_user: UserClass, bid_user: dict):
|
||||||
|
"""Updates the subscription status of the user given the Blender ID user info.
|
||||||
|
|
||||||
|
Uses the badger service to update the user's roles from Blender ID.
|
||||||
|
|
||||||
|
bid_user should be a dict like:
|
||||||
|
{'id': 1234,
|
||||||
|
'full_name': 'मूंगफली मक्खन प्रेमी',
|
||||||
|
'email': 'here@example.com',
|
||||||
|
'roles': {'cloud_demo': True}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pillar.api import service
|
||||||
|
|
||||||
|
my_log: logging.Logger = log.getChild('do_update_subscription')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
email = bid_user['email']
|
email = bid_user['email']
|
||||||
except KeyError:
|
except KeyError:
|
||||||
my_log.error('Blender ID response did not include an email address, '
|
email = '-missing email-'
|
||||||
'unable to update subscription status: %s',
|
|
||||||
pprint.pformat(bid_user, compact=True))
|
|
||||||
return 'Internal error', 500
|
|
||||||
store_user = fetch_subscription_info(email) or {}
|
|
||||||
|
|
||||||
# Handle the role changes via the badger service functionality.
|
# Handle the role changes via the badger service functionality.
|
||||||
grant_subscriber = store_user.get('cloud_access', 0) == 1
|
bid_roles = collections.defaultdict(bool, **bid_user.get('roles', {}))
|
||||||
grant_demo = bid_user.get('roles', {}).get('cloud_demo', False)
|
plr_roles = set(local_user.roles)
|
||||||
|
|
||||||
is_subscriber = authorization.user_has_role('subscriber')
|
grant_roles = set()
|
||||||
is_demo = authorization.user_has_role('demo')
|
revoke_roles = set()
|
||||||
|
for bid_role, plr_role in ROLES_BID_TO_PILLAR.items():
|
||||||
|
if bid_roles[bid_role] and plr_role not in plr_roles:
|
||||||
|
grant_roles.add(plr_role)
|
||||||
|
continue
|
||||||
|
if not bid_roles[bid_role] and plr_role in plr_roles:
|
||||||
|
revoke_roles.add(plr_role)
|
||||||
|
|
||||||
if grant_subscriber != is_subscriber:
|
user_id = local_user.user_id
|
||||||
action = 'grant' if grant_subscriber else 'revoke'
|
|
||||||
my_log.info('%sing subscriber role to user %s (Blender ID email %s)',
|
|
||||||
action, user_id, email)
|
|
||||||
service.do_badger(action, role='subscriber', user_id=user_id)
|
|
||||||
else:
|
|
||||||
my_log.debug('Not changing subscriber role, grant=%r and is=%s',
|
|
||||||
grant_subscriber, is_subscriber)
|
|
||||||
|
|
||||||
if grant_demo != is_demo:
|
if grant_roles:
|
||||||
action = 'grant' if grant_demo else 'revoke'
|
if my_log.isEnabledFor(logging.INFO):
|
||||||
my_log.info('%sing demo role to user %s (Blender ID email %s)', action, user_id, email)
|
my_log.info('granting roles to user %s (Blender ID %s): %s',
|
||||||
service.do_badger(action, role='demo', user_id=user_id)
|
user_id, email, ', '.join(sorted(grant_roles)))
|
||||||
else:
|
service.do_badger('grant', roles=grant_roles, user_id=user_id)
|
||||||
my_log.debug('Not changing demo role, grant=%r and is=%s',
|
|
||||||
grant_demo, is_demo)
|
|
||||||
|
|
||||||
return '', 204
|
if revoke_roles:
|
||||||
|
if my_log.isEnabledFor(logging.INFO):
|
||||||
|
my_log.info('revoking roles to user %s (Blender ID %s): %s',
|
||||||
|
user_id, email, ', '.join(sorted(revoke_roles)))
|
||||||
|
service.do_badger('revoke', roles=revoke_roles, user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
def setup_app(app, url_prefix):
|
def setup_app(app, url_prefix):
|
||||||
|
@ -181,7 +181,8 @@ def fetch_blenderid_user() -> dict:
|
|||||||
"roles": {
|
"roles": {
|
||||||
"admin": true,
|
"admin": true,
|
||||||
"bfct_trainer": false,
|
"bfct_trainer": false,
|
||||||
"cloud_single_member": true,
|
"cloud_has_subscription": true,
|
||||||
|
"cloud_subscriber": true,
|
||||||
"conference_speaker": true,
|
"conference_speaker": true,
|
||||||
"network_member": true
|
"network_member": true
|
||||||
}
|
}
|
||||||
@ -218,12 +219,13 @@ def fetch_blenderid_user() -> dict:
|
|||||||
log.warning('Error %i from BlenderID %s: %s', bid_resp.status_code, bid_url, bid_resp.text)
|
log.warning('Error %i from BlenderID %s: %s', bid_resp.status_code, bid_url, bid_resp.text)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
if not bid_resp.json():
|
payload = bid_resp.json()
|
||||||
|
if not payload:
|
||||||
log.warning('Empty data returned from BlenderID %s', bid_url)
|
log.warning('Empty data returned from BlenderID %s', bid_url)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
log.debug('BlenderID returned %s', bid_resp.json())
|
log.debug('BlenderID returned %s', payload)
|
||||||
return bid_resp.json()
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def setup_app(app, url_prefix):
|
def setup_app(app, url_prefix):
|
||||||
|
@ -99,6 +99,11 @@ FULL_FILE_ACCESS_ROLES = {'admin', 'subscriber', 'demo'}
|
|||||||
BLENDER_ID_CLIENT_ID = 'SPECIAL-SNOWFLAKE-57'
|
BLENDER_ID_CLIENT_ID = 'SPECIAL-SNOWFLAKE-57'
|
||||||
BLENDER_ID_SUBCLIENT_ID = 'PILLAR'
|
BLENDER_ID_SUBCLIENT_ID = 'PILLAR'
|
||||||
|
|
||||||
|
# Blender ID user info API endpoint URL and auth token, only used for
|
||||||
|
# reconciling subscribers. The token requires the 'userinfo' scope.
|
||||||
|
BLENDER_ID_USER_INFO_API = 'http://blender-id:8000/api/user/'
|
||||||
|
BLENDER_ID_USER_INFO_TOKEN = '-set-in-config-local-'
|
||||||
|
|
||||||
# Collection of supported OAuth providers (Blender ID, Facebook and Google).
|
# Collection of supported OAuth providers (Blender ID, Facebook and Google).
|
||||||
# Example entry:
|
# Example entry:
|
||||||
# OAUTH_CREDENTIALS = {
|
# OAUTH_CREDENTIALS = {
|
||||||
@ -173,9 +178,6 @@ URLER_SERVICE_AUTH_TOKEN = None
|
|||||||
# front-end.
|
# front-end.
|
||||||
BLENDER_CLOUD_ADDON_VERSION = '1.4'
|
BLENDER_CLOUD_ADDON_VERSION = '1.4'
|
||||||
|
|
||||||
EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER = 'https://store.blender.org/api/'
|
|
||||||
EXTERNAL_SUBSCRIPTIONS_TIMEOUT_SECS = 10
|
|
||||||
|
|
||||||
# Certificate file for communication with other systems.
|
# Certificate file for communication with other systems.
|
||||||
TLS_CERT_FILE = requests.certs.where()
|
TLS_CERT_FILE = requests.certs.where()
|
||||||
|
|
||||||
|
@ -69,8 +69,7 @@ def oauth_callback(provider):
|
|||||||
pillar.auth.login_user(token['token'], load_from_db=True)
|
pillar.auth.login_user(token['token'], load_from_db=True)
|
||||||
|
|
||||||
if provider == 'blender-id' and current_user.is_authenticated:
|
if provider == 'blender-id' and current_user.is_authenticated:
|
||||||
# Check with the store for user roles. If the user has an active subscription, we apply
|
# Check with Blender ID to update certain user roles.
|
||||||
# the 'subscriber' role
|
|
||||||
update_subscription()
|
update_subscription()
|
||||||
|
|
||||||
next_after_login = session.pop('next_after_login', None)
|
next_after_login = session.pop('next_after_login', None)
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import typing
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import responses
|
import responses
|
||||||
@ -17,22 +18,16 @@ class RoleUpdatingTest(AbstractPillarTest):
|
|||||||
self.create_standard_groups()
|
self.create_standard_groups()
|
||||||
|
|
||||||
def _setup_testcase(self, mocked_fetch_blenderid_user, *,
|
def _setup_testcase(self, mocked_fetch_blenderid_user, *,
|
||||||
store_says_cloud_access: bool,
|
bid_roles: typing.Set[str]):
|
||||||
bid_says_cloud_demo: bool):
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
|
# The Store API endpoint should not be called upon any more.
|
||||||
url = '%s?blenderid=%s' % (self.app.config['EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER'],
|
url = '%s?blenderid=%s' % (self.app.config['EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER'],
|
||||||
urllib.parse.quote(TEST_EMAIL_ADDRESS))
|
urllib.parse.quote(TEST_EMAIL_ADDRESS))
|
||||||
responses.add('GET', url,
|
responses.add('GET', url,
|
||||||
json={'shop_id': 58432,
|
status=500,
|
||||||
'cloud_access': 1 if store_says_cloud_access else 0,
|
|
||||||
'paid_balance': 0,
|
|
||||||
'balance_currency': 'EUR',
|
|
||||||
'start_date': '2017-05-04 12:07:49',
|
|
||||||
'expiration_date': '2017-08-04 10:07:49',
|
|
||||||
'subscription_status': 'wc-active'
|
|
||||||
},
|
|
||||||
status=200,
|
|
||||||
match_querystring=True)
|
match_querystring=True)
|
||||||
|
|
||||||
self.mock_blenderid_validate_happy()
|
self.mock_blenderid_validate_happy()
|
||||||
mocked_fetch_blenderid_user.return_value = {
|
mocked_fetch_blenderid_user.return_value = {
|
||||||
'email': TEST_EMAIL_ADDRESS,
|
'email': TEST_EMAIL_ADDRESS,
|
||||||
@ -45,27 +40,25 @@ class RoleUpdatingTest(AbstractPillarTest):
|
|||||||
'network_member': True
|
'network_member': True
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if bid_says_cloud_demo:
|
for role in bid_roles:
|
||||||
mocked_fetch_blenderid_user.return_value['roles']['cloud_demo'] = True
|
mocked_fetch_blenderid_user.return_value['roles'][role] = True
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
@mock.patch('pillar.api.blender_id.fetch_blenderid_user')
|
@mock.patch('pillar.api.blender_id.fetch_blenderid_user')
|
||||||
def test_store_api_role_grant_subscriber(self, mocked_fetch_blenderid_user):
|
def test_store_api_role_grant_subscriber(self, mocked_fetch_blenderid_user):
|
||||||
self._setup_testcase(mocked_fetch_blenderid_user,
|
self._setup_testcase(mocked_fetch_blenderid_user,
|
||||||
store_says_cloud_access=True,
|
bid_roles={'cloud_subscriber', 'cloud_has_subscription'})
|
||||||
bid_says_cloud_demo=False)
|
|
||||||
|
|
||||||
self.get('/api/bcloud/update-subscription', auth_token='my-happy-token',
|
self.get('/api/bcloud/update-subscription', auth_token='my-happy-token',
|
||||||
expected_status=204)
|
expected_status=204)
|
||||||
user_info = self.get('/api/users/me', auth_token='my-happy-token').json()
|
user_info = self.get('/api/users/me', auth_token='my-happy-token').json()
|
||||||
self.assertEqual(['subscriber'], user_info['roles'])
|
self.assertEqual({'subscriber', 'has_subscription'}, set(user_info['roles']))
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
@mock.patch('pillar.api.blender_id.fetch_blenderid_user')
|
@mock.patch('pillar.api.blender_id.fetch_blenderid_user')
|
||||||
def test_store_api_role_revoke_subscriber(self, mocked_fetch_blenderid_user):
|
def test_store_api_role_revoke_subscriber(self, mocked_fetch_blenderid_user):
|
||||||
self._setup_testcase(mocked_fetch_blenderid_user,
|
self._setup_testcase(mocked_fetch_blenderid_user,
|
||||||
store_says_cloud_access=False,
|
bid_roles={'conference_speaker'})
|
||||||
bid_says_cloud_demo=False)
|
|
||||||
|
|
||||||
# Make sure this user is currently known as a subcriber.
|
# Make sure this user is currently known as a subcriber.
|
||||||
self.create_user(roles={'subscriber'}, token='my-happy-token')
|
self.create_user(roles={'subscriber'}, token='my-happy-token')
|
||||||
@ -82,8 +75,7 @@ class RoleUpdatingTest(AbstractPillarTest):
|
|||||||
@mock.patch('pillar.api.blender_id.fetch_blenderid_user')
|
@mock.patch('pillar.api.blender_id.fetch_blenderid_user')
|
||||||
def test_bid_api_grant_demo(self, mocked_fetch_blenderid_user):
|
def test_bid_api_grant_demo(self, mocked_fetch_blenderid_user):
|
||||||
self._setup_testcase(mocked_fetch_blenderid_user,
|
self._setup_testcase(mocked_fetch_blenderid_user,
|
||||||
store_says_cloud_access=False,
|
bid_roles={'cloud_demo'})
|
||||||
bid_says_cloud_demo=True)
|
|
||||||
|
|
||||||
self.get('/api/bcloud/update-subscription', auth_token='my-happy-token',
|
self.get('/api/bcloud/update-subscription', auth_token='my-happy-token',
|
||||||
expected_status=204)
|
expected_status=204)
|
||||||
@ -93,10 +85,9 @@ class RoleUpdatingTest(AbstractPillarTest):
|
|||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
@mock.patch('pillar.api.blender_id.fetch_blenderid_user')
|
@mock.patch('pillar.api.blender_id.fetch_blenderid_user')
|
||||||
def test_bid_api_role_revoke_subscriber(self, mocked_fetch_blenderid_user):
|
def test_bid_api_role_revoke_demo(self, mocked_fetch_blenderid_user):
|
||||||
self._setup_testcase(mocked_fetch_blenderid_user,
|
self._setup_testcase(mocked_fetch_blenderid_user,
|
||||||
store_says_cloud_access=False,
|
bid_roles={'conference_speaker'})
|
||||||
bid_says_cloud_demo=False)
|
|
||||||
|
|
||||||
# Make sure this user is currently known as demo user.
|
# Make sure this user is currently known as demo user.
|
||||||
self.create_user(roles={'demo'}, token='my-happy-token')
|
self.create_user(roles={'demo'}, token='my-happy-token')
|
||||||
|
Loading…
x
Reference in New Issue
Block a user