Merge branch 'master' into elastic

This commit is contained in:
Sybren A. Stüvel 2017-12-08 12:55:57 +01:00
commit b7773e69c7
22 changed files with 289 additions and 214 deletions

View File

@ -21,6 +21,7 @@ from flask_babel import Babel, gettext as _
from flask.templating import TemplateNotFound from flask.templating import TemplateNotFound
import pymongo.collection import pymongo.collection
import pymongo.database import pymongo.database
from raven.contrib.flask import Sentry
from werkzeug.local import LocalProxy from werkzeug.local import LocalProxy
@ -59,7 +60,17 @@ class ConfigurationMissingError(SystemExit):
""" """
class PillarServer(Eve): class BlinkerCompatibleEve(Eve):
"""Workaround for https://github.com/pyeve/eve/issues/1087"""
def __getattr__(self, name):
if name in {"im_self", "im_func"}:
raise AttributeError("type object '%s' has no attribute '%s'" %
(self.__class__.__name__, name))
return super().__getattr__(name)
class PillarServer(BlinkerCompatibleEve):
def __init__(self, app_root, **kwargs): def __init__(self, app_root, **kwargs):
from .extension import PillarExtension from .extension import PillarExtension
from celery import Celery from celery import Celery
@ -75,7 +86,7 @@ class PillarServer(Eve):
# The default roles Pillar uses. Will probably all move to extensions at some point. # The default roles Pillar uses. Will probably all move to extensions at some point.
self._user_roles: typing.Set[str] = { self._user_roles: typing.Set[str] = {
'demo', 'admin', 'subscriber', 'homeproject', 'demo', 'admin', 'subscriber', 'homeproject',
'protected', 'protected', 'org-subscriber', 'video-encoder',
'service', 'badger', 'svner', 'urler', 'service', 'badger', 'svner', 'urler',
} }
self._user_roles_indexable: typing.Set[str] = {'demo', 'admin', 'subscriber'} self._user_roles_indexable: typing.Set[str] = {'demo', 'admin', 'subscriber'}
@ -94,7 +105,9 @@ class PillarServer(Eve):
self._config_auth_token_hmac_key() self._config_auth_token_hmac_key()
self._config_tempdirs() self._config_tempdirs()
self._config_git() self._config_git()
self._config_bugsnag()
self.sentry: typing.Optional[Sentry] = None
self._config_sentry()
self._config_google_cloud_storage() self._config_google_cloud_storage()
self.algolia_index_users = None self.algolia_index_users = None
@ -187,39 +200,19 @@ class PillarServer(Eve):
self.config['GIT_REVISION'] = 'unknown' self.config['GIT_REVISION'] = 'unknown'
self.log.info('Git revision %r', self.config['GIT_REVISION']) self.log.info('Git revision %r', self.config['GIT_REVISION'])
def _config_bugsnag(self): def _config_sentry(self):
bugsnag_api_key = self.config.get('BUGSNAG_API_KEY') sentry_dsn = self.config.get('SENTRY_CONFIG', {}).get('dsn')
if self.config.get('TESTING') or not bugsnag_api_key: if self.config.get('TESTING') or sentry_dsn in {'', '-set-in-config-local-'}:
self.log.info('Bugsnag NOT configured.') self.log.warning('Sentry NOT configured.')
self.sentry = None
return return
import bugsnag self.sentry = Sentry(self, logging=True, level=logging.WARNING,
from bugsnag.handlers import BugsnagHandler logging_exclusions=('werkzeug',))
release_stage = self.config.get('BUGSNAG_RELEASE_STAGE', 'unconfigured') # bugsnag.before_notify(bugsnag_extra.add_pillar_request_to_notification)
if self.config.get('DEBUG'): # got_request_exception.connect(self.__notify_bugsnag)
release_stage += '-debug' self.log.info('Sentry setup complete')
bugsnag.configure(
api_key=bugsnag_api_key,
project_root="/data/git/pillar/pillar",
release_stage=release_stage
)
bs_handler = BugsnagHandler()
bs_handler.setLevel(logging.ERROR)
self.log.addHandler(bs_handler)
# This is what bugsnag.flask.handle_exceptions also tries to do,
# but it passes the app to the connect() call, which causes an
# error. Since we only have one app, we can do without.
from flask import got_request_exception
from . import bugsnag_extra
bugsnag.before_notify(bugsnag_extra.add_pillar_request_to_notification)
got_request_exception.connect(self.__notify_bugsnag)
self.log.info('Bugsnag setup complete')
def __notify_bugsnag(self, sender, exception, **extra): def __notify_bugsnag(self, sender, exception, **extra):
import bugsnag import bugsnag

View File

@ -1,94 +1,37 @@
import logging import logging
import typing import typing
from flask import current_app, Blueprint from flask import Blueprint, Response
import requests
from requests.adapters import HTTPAdapter
from pillar.api.utils import authorization from pillar import auth, current_app
from pillar.api import blender_id
from pillar.api.utils import authorization, jsonify
from pillar.auth import current_user
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.api import blender_id, service
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,125 @@ 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).
@blueprint.route('/update-subscription-for/<user_id>', methods=['POST'])
@authorization.require_login(require_cap='admin')
def update_subscription_for(user_id: str):
"""Updates the user based on their info at Blender ID."""
from urllib.parse import urljoin
from pillar.api.utils import str2id
my_log = log.getChild('update_subscription_for')
bid_session = requests.Session()
bid_session.mount('https://', HTTPAdapter(max_retries=5))
bid_session.mount('http://', HTTPAdapter(max_retries=5))
users_coll = current_app.db('users')
db_user = users_coll.find_one({'_id': str2id(user_id)})
if not db_user:
my_log.warning('User %s not found in database', user_id)
return Response(f'User {user_id} not found in our database', status=404)
log.info('Updating user %s from Blender ID on behalf of %s',
db_user['email'], current_user.email)
bid_user_id = blender_id.get_user_blenderid(db_user)
if not bid_user_id:
my_log.info('User %s has no Blender ID', user_id)
return Response('User has no Blender ID', status=404)
# Get the user info from Blender ID, and handle errors.
api_url = current_app.config['BLENDER_ID_USER_INFO_API']
api_token = current_app.config['BLENDER_ID_USER_INFO_TOKEN']
url = urljoin(api_url, bid_user_id)
resp = bid_session.get(url, headers={'Authorization': f'Bearer {api_token}'})
if resp.status_code == 404:
my_log.info('User %s has a Blender ID %s but Blender ID itself does not find it',
user_id, bid_user_id)
return Response(f'User {bid_user_id} does not exist at Blender ID', status=404)
if resp.status_code != 200:
my_log.info('Error code %s getting user %s from Blender ID (resp = %s)',
resp.status_code, user_id, resp.text)
return Response(f'Error code {resp.status_code} from Blender ID', status=resp.status_code)
# Update the user in our database.
local_user = auth.UserClass.construct('', db_user)
bid_user = resp.json()
do_update_subscription(local_user, bid_user)
return '', 204
def do_update_subscription(local_user: auth.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}}
The 'roles' key can also be an interable of role names instead of a dict.
"""
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)) # Transform the BID roles from a dict to a set.
return 'Internal error', 500 bidr = bid_user.get('roles', set())
store_user = fetch_subscription_info(email) or {} if isinstance(bidr, dict):
bid_roles = {role
for role, has_role in bid_user.get('roles', {}).items()
if has_role}
else:
bid_roles = set(bidr)
# 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 plr_roles = set(local_user.roles)
grant_demo = bid_user.get('roles', {}).get('cloud_demo', False)
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_role in bid_roles and plr_role not in plr_roles:
grant_roles.add(plr_role)
continue
if bid_role not in bid_roles 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)
# Re-index the user in the search database.
from pillar.api.users import hooks
hooks.push_updated_user_to_algolia({'_id': user_id}, {})
def setup_app(app, url_prefix): def setup_app(app, url_prefix):

View File

@ -168,6 +168,24 @@ def _compute_token_expiry(token_expires_string):
return min(blid_expiry, our_expiry) return min(blid_expiry, our_expiry)
def get_user_blenderid(db_user: dict) -> str:
"""Returns the Blender ID user ID for this Pillar user.
Takes the string from 'auth.*.user_id' for the '*' where 'provider'
is 'blender-id'.
:returns the user ID, or the empty string when the user has none.
"""
bid_user_ids = [auth['user_id']
for auth in db_user['auth']
if auth['provider'] == 'blender-id']
try:
return bid_user_ids[0]
except IndexError:
return ''
def fetch_blenderid_user() -> dict: def fetch_blenderid_user() -> dict:
"""Returns the user info of the currently logged in user from BlenderID. """Returns the user info of the currently logged in user from BlenderID.
@ -181,7 +199,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 +237,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):

View File

@ -26,7 +26,7 @@ from flask import url_for, helpers
from pillar.api import utils from pillar.api import utils
from pillar.api.file_storage_backends.gcs import GoogleCloudStorageBucket, \ from pillar.api.file_storage_backends.gcs import GoogleCloudStorageBucket, \
GoogleCloudStorageBlob GoogleCloudStorageBlob
from pillar.api.utils import remove_private_keys, authentication from pillar.api.utils import remove_private_keys
from pillar.api.utils.authorization import require_login, user_has_role, \ from pillar.api.utils.authorization import require_login, user_has_role, \
user_matches_roles user_matches_roles
from pillar.api.utils.cdn import hash_file_path from pillar.api.utils.cdn import hash_file_path
@ -291,8 +291,8 @@ def process_file(bucket: Bucket,
# TODO: overrule the content type based on file extention & magic numbers. # TODO: overrule the content type based on file extention & magic numbers.
mime_category, src_file['format'] = src_file['content_type'].split('/', 1) mime_category, src_file['format'] = src_file['content_type'].split('/', 1)
# Prevent video handling for non-admins. # Only allow video encoding when the user has the correct capability.
if not user_has_role('admin') and mime_category == 'video': if not current_user.has_cap('encode-video') and mime_category == 'video':
if src_file['format'].startswith('x-'): if src_file['format'].startswith('x-'):
xified = src_file['format'] xified = src_file['format']
else: else:
@ -300,7 +300,7 @@ def process_file(bucket: Bucket,
src_file['content_type'] = 'application/%s' % xified src_file['content_type'] = 'application/%s' % xified
mime_category = 'application' mime_category = 'application'
log.info('Not processing video file %s for non-admin user', file_id) log.info('Not processing video file %s for non-video-encoding user', file_id)
# Run the required processor, based on the MIME category. # Run the required processor, based on the MIME category.
processors: typing.Mapping[str, typing.Callable] = { processors: typing.Mapping[str, typing.Callable] = {

View File

@ -18,7 +18,7 @@ log = logging.getLogger(__name__)
CAPABILITIES = collections.defaultdict(**{ CAPABILITIES = collections.defaultdict(**{
'subscriber': {'subscriber', 'home-project'}, 'subscriber': {'subscriber', 'home-project'},
'demo': {'subscriber', 'home-project'}, 'demo': {'subscriber', 'home-project'},
'admin': {'subscriber', 'home-project', 'video-encoding', 'admin', 'admin': {'video-encoding', 'admin',
'view-pending-nodes', 'edit-project-node-types'}, 'view-pending-nodes', 'edit-project-node-types'},
}, default_factory=frozenset) }, default_factory=frozenset)

View File

@ -65,8 +65,13 @@ GOOGLE_SITE_VERIFICATION = ''
ADMIN_USER_GROUP = '5596e975ea893b269af85c0e' ADMIN_USER_GROUP = '5596e975ea893b269af85c0e'
SUBSCRIBER_USER_GROUP = '5596e975ea893b269af85c0f' SUBSCRIBER_USER_GROUP = '5596e975ea893b269af85c0f'
BUGSNAG_API_KEY = ''
BUGSNAG_RELEASE_STAGE = 'development' SENTRY_CONFIG = {
'dsn': '-set-in-config-local-',
# 'release': raven.fetch_git_sha(os.path.dirname(__file__)),
}
# See https://docs.sentry.io/clients/python/integrations/flask/#settings
SENTRY_USER_ATTRS = ['username', 'full_name', 'email', 'objectid']
ALGOLIA_USER = '-SECRET-' ALGOLIA_USER = '-SECRET-'
ALGOLIA_API_KEY = '-SECRET-' ALGOLIA_API_KEY = '-SECRET-'
@ -106,6 +111,12 @@ 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, used for
# reconciling subscribers and updating their info from /u/.
# 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 = {
@ -180,9 +191,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()
@ -204,8 +212,9 @@ CELERY_BEAT_SCHEDULE = {
USER_CAPABILITIES = defaultdict(**{ USER_CAPABILITIES = defaultdict(**{
'subscriber': {'subscriber', 'home-project'}, 'subscriber': {'subscriber', 'home-project'},
'demo': {'subscriber', 'home-project'}, 'demo': {'subscriber', 'home-project'},
'admin': {'subscriber', 'home-project', 'video-encoding', 'admin', 'admin': {'encode-video', 'admin',
'view-pending-nodes', 'edit-project-node-types', 'create-organization'}, 'view-pending-nodes', 'edit-project-node-types', 'create-organization'},
'video-encoder': {'encode-video'},
'org-subscriber': {'subscriber', 'home-project'}, 'org-subscriber': {'subscriber', 'home-project'},
}, default_factory=frozenset) }, default_factory=frozenset)

View File

@ -134,7 +134,7 @@ def comments_for_node(node_id):
project = Project({'_id': node.project}) project = Project({'_id': node.project})
can_post_comments = project.node_type_has_method('comment', 'POST', api=api) can_post_comments = project.node_type_has_method('comment', 'POST', api=api)
can_comment_override = request.args.get('can_comment', 'True') == 'True' can_comment_override = request.args.get('can_comment', 'True') == 'True'
can_post_comments = can_post_comments and can_comment_override can_post_comments = can_post_comments and can_comment_override and current_user.has_cap('subscriber')
# Query for all children, i.e. comments on the node. # Query for all children, i.e. comments on the node.
comments = Node.all({ comments = Node.all({

View File

@ -23,13 +23,10 @@ def profile():
api = system_util.pillar_api() api = system_util.pillar_api()
user = User.find(current_user.objectid, api=api) user = User.find(current_user.objectid, api=api)
form = forms.UserProfileForm( form = forms.UserProfileForm(username=user.username)
full_name=user.full_name,
username=user.username)
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
user.full_name = form.full_name.data
user.username = form.username.data user.username = form.username.data
user.update(api=api) user.update(api=api)
flash("Profile updated", 'success') flash("Profile updated", 'success')

View File

@ -24,17 +24,16 @@ class UserLoginForm(Form):
class UserProfileForm(Form): class UserProfileForm(Form):
full_name = StringField('Full Name', validators=[DataRequired(), Length(
min=3, max=128, message="Min. 3 and max. 128 chars please")])
username = StringField('Username', validators=[DataRequired(), Length( username = StringField('Username', validators=[DataRequired(), Length(
min=3, max=128, message="Min. 3, max. 128 chars please"), Regexp( min=3, max=128, message="Min. 3, max. 128 chars please"), Regexp(
r'^[\w.@+-]+$', message="Please do not use spaces")]) r'^[\w.@+-]+$', message="Please do not use spaces")])
def __init__(self, csrf_enabled=False, *args, **kwargs): def __init__(self, csrf_enabled=False, *args, **kwargs):
super(UserProfileForm, self).__init__(csrf_enabled=False, *args, **kwargs) super().__init__(csrf_enabled=csrf_enabled, *args, **kwargs)
self.user = None
def validate(self): def validate(self):
rv = Form.validate(self) rv = super().validate()
if not rv: if not rv:
return False return False
@ -42,11 +41,11 @@ class UserProfileForm(Form):
user = User.find(current_user.objectid, api=api) user = User.find(current_user.objectid, api=api)
if user.username != self.username.data: if user.username != self.username.data:
username = User.find_first( username = User.find_first(
{'where': '{"username": "%s"}' % self.username.data}, {'where': {"username": self.username.data}},
api=api) api=api)
if username: if username:
self.username.errors.append('Sorry, username already exists!') self.username.errors.append('Sorry, this username is already taken.')
return False return False
self.user = user self.user = user

View File

@ -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)

View File

@ -5,12 +5,11 @@ attrs==16.2.0
algoliasearch==1.12.0 algoliasearch==1.12.0
bcrypt==3.1.3 bcrypt==3.1.3
blinker==1.4 blinker==1.4
bugsnag[flask]==3.1.1
bleach==1.4.3 bleach==1.4.3
celery[redis]==4.0.2 celery[redis]==4.0.2
CommonMark==0.7.2 CommonMark==0.7.2
Eve==0.7.3 Eve==0.7.3
Flask==0.12.2 Flask==0.12
Flask-Babel==0.11.2 Flask-Babel==0.11.2
Flask-Cache==0.13.1 Flask-Cache==0.13.1
Flask-Script==2.0.6 Flask-Script==2.0.6
@ -19,11 +18,12 @@ Flask-WTF==0.12
gcloud==0.12.0 gcloud==0.12.0
google-apitools==0.4.11 google-apitools==0.4.11
httplib2==0.9.2 httplib2==0.9.2
MarkupSafe==1.0 MarkupSafe==0.23
ndg-httpsclient==0.4.0 ndg-httpsclient==0.4.0
Pillow==4.1.1 Pillow==4.1.1
python-dateutil==2.5.3 python-dateutil==2.5.3
rauth==0.7.3 rauth==0.7.3
raven[flask]==6.3.0
redis==2.10.5 redis==2.10.5
WebOb==1.5.0 WebOb==1.5.0
wheel==0.29.0 wheel==0.29.0
@ -55,4 +55,4 @@ simplejson==3.10.0
six==1.10.0 six==1.10.0
vine==1.1.3 vine==1.1.3
WTForms==2.1 WTForms==2.1
Werkzeug==0.12.2 Werkzeug==0.11.15

View File

@ -40,6 +40,11 @@ li(class="dropdown")
title="View subscription info") title="View subscription info")
i.pi-heart-filled i.pi-heart-filled
span You have a free account. span You have a free account.
| {% elif current_user.has_cap('can-renew-subscription') %}
a.navbar-item(target='_blank', href="/renew", title="Renew subscription")
i.pi-heart
span.info Your subscription is not active.
span.renew Click here to renew.
| {% else %} | {% else %}
a.navbar-item( a.navbar-item(
href="https://store.blender.org/product/membership/" href="https://store.blender.org/product/membership/"

View File

@ -2,7 +2,7 @@ doctype
html(lang="en") html(lang="en")
head head
meta(charset="utf-8") meta(charset="utf-8")
title Error title {% block title %}Error{% endblock %}
meta(name="viewport", content="width=device-width, initial-scale=1.0") meta(name="viewport", content="width=device-width, initial-scale=1.0")
link(href="{{ url_for('static_pillar', filename='assets/ico/favicon.png') }}", rel="shortcut icon") link(href="{{ url_for('static_pillar', filename='assets/ico/favicon.png') }}", rel="shortcut icon")
@ -10,7 +10,7 @@ html(lang="en")
link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet") link(href="{{ url_for('static_pillar', filename='assets/css/font-pillar.css') }}", rel="stylesheet")
link(href="{{ url_for('static_pillar', filename='assets/css/base.css') }}", rel="stylesheet") link(href="{{ url_for('static_pillar', filename='assets/css/base.css') }}", rel="stylesheet")
link(href='//fonts.googleapis.com/css?family=Roboto:300,400', rel='stylesheet', type='text/css') link(href='//fonts.googleapis.com/css?family=Roboto:300,400', rel='stylesheet', type='text/css')
| {% block head %}{% endblock %}
body.error body.error
| {% block body %}{% endblock %} | {% block body %}{% endblock %}

View File

@ -74,6 +74,15 @@
| Download | Download
| {% endif %} | {% endif %}
| {% elif current_user.has_cap('can-renew-subscription') %}
li.download
a.btn.btn-success(
title="Renew your subscription to download",
target="_blank",
href="/renew")
i.pi-heart
| Renew subscription to download
| {% elif current_user.is_authenticated %} | {% elif current_user.is_authenticated %}
li.download li.download
a.btn( a.btn(

View File

@ -27,8 +27,14 @@
span span
small Support Blender and get awesome stuff! small Support Blender and get awesome stuff!
hr hr
a.subscribe(href="{{ url_for('cloud.join') }}") <em>Subscribe</em> | {% if current_user.has_cap('can-renew-subscription') %}
a.subscribe(href="/renew") You have a subscription, it just needs to be renewed. <em>Renew your subscription now!</em>
| {% else %}
a.subscribe(href="{{ url_for('cloud.join') }}") <em>Subscribe to Blender Cloud.</em>
| {% endif %}
| {% if current_user.is_anonymous %}
a(href="{{ url_for('users.login') }}") Already a subscriber? Log in a(href="{{ url_for('users.login') }}") Already a subscriber? Log in
| {% endif %}
| {% endif %} | {% endif %}

View File

@ -49,6 +49,9 @@
| {% if current_user.has_cap('subscriber') %} | {% if current_user.has_cap('subscriber') %}
i.pi-lock i.pi-lock
| Only project members can comment. | Only project members can comment.
| {% elif current_user.has_cap('can-renew-subscription') %}
i.pi-heart
a(href='/renew', target='_blank') Renew your subscription to join the conversation!
| {% else %} | {% else %}
| Join the conversation!&nbsp;<a href="https://store.blender.org/product/membership/">Subscribe to Blender Cloud</a> now. | Join the conversation!&nbsp;<a href="https://store.blender.org/product/membership/">Subscribe to Blender Cloud</a> now.
| {% endif %} | {% endif %}

View File

@ -40,12 +40,17 @@ meta(name="twitter:image", content="{{ url_for('static', filename='assets/img/ba
| {% endif %} | {% endif %}
| {% if current_user.has_cap('subscriber') %} | {% if current_user.has_cap('subscriber') %}
li.create( li.create#project-create(
data-url="{{ url_for('projects.create') }}") data-url="{{ url_for('projects.create') }}")
a.btn.btn-success#project-create( a.btn.btn-success(
href="{{ url_for('projects.create') }}") href="{{ url_for('projects.create') }}")
i.pi-plus i.pi-plus
| Create Project | Create Project
| {% elif current_user.has_cap('can-renew-subscription') %}
li.create
a.btn(href="/renew", target="_blank")
i.pi-heart
| Renew subscription to create a project
| {% endif %} | {% endif %}
nav.nav-tabs__tab.active#own_projects nav.nav-tabs__tab.active#own_projects
@ -83,6 +88,13 @@ meta(name="twitter:image", content="{{ url_for('static', filename='assets/img/ba
.projects__list-details .projects__list-details
a.title(href="{{ url_for('projects.create') }}") a.title(href="{{ url_for('projects.create') }}")
| Create a project to get started! | Create a project to get started!
| {% elif current_user.has_cap('can-renew-subscription') %}
li.projects__list-item(data-url="https://store.blender.org/renew-my-subscription.php")
a.projects__list-thumbnail
i.pi-plus
.projects__list-details
a.title(href="https://store.blender.org/renew-my-subscription.php")
| Renew your Blender Cloud subscription to create your own projects!
| {% else %} | {% else %}
li.projects__list-item(data-url="/join") li.projects__list-item(data-url="/join")
a.projects__list-thumbnail a.projects__list-thumbnail
@ -222,7 +234,7 @@ script.
}); });
// Create project // Create project
$nav_tabs_list.find('li.create').on('click', function(e){ $('#project-create').on('click', function(e){
e.preventDefault(); e.preventDefault();
$(this).addClass('disabled'); $(this).addClass('disabled');

View File

@ -72,7 +72,8 @@
| none | none
| {% endif %} | {% endif %}
a#button-cancel.btn.btn-default(href="#", data-user-id='{{user.user_id}}') Cancel a.btn.btn-default(href="javascript:update_from_bid()") Update from Blender ID
a.btn.btn-default(href="javascript:$('#user-edit-container').html('')") Cancel
input#submit_edit_user.btn.btn-default( input#submit_edit_user.btn.btn-default(
data-user-id="{{user.user_id}}", data-user-id="{{user.user_id}}",
@ -101,10 +102,18 @@ script(type="text/javascript").
//- $("#user-edit-form").submit(); //- $("#user-edit-form").submit();
}); });
$('#button-cancel').click(function(e){
$('#user-container').html('')
});
new Clipboard('.copy-to-clipboard'); new Clipboard('.copy-to-clipboard');
function update_from_bid() {
var url = '{{ url_for("blender_cloud.subscription.update_subscription_for", user_id=user.user_id) }}';
$.post(url)
.done(function(data) {
toastr.info('User updated from Blender ID');
displayUser('{{ user.user_id }}');
})
.fail(function(data) {
toastr.error(data.responseText);
});
}
| {% endblock %} | {% endblock %}

View File

@ -11,13 +11,6 @@
.settings-form .settings-form
form#settings-form(method='POST', action="{{url_for('settings.profile')}}") form#settings-form(method='POST', action="{{url_for('settings.profile')}}")
.left .left
.form-group
| {{ form.full_name.label }}
| {{ form.full_name(size=20, class='form-control') }}
| {% if form.full_name.errors %}
| {% for error in form.full_name.errors %}{{ error|e }}{% endfor %}
| {% endif %}
.form-group .form-group
| {{ form.username.label }} | {{ form.username.label }}
| {{ form.username(size=20, class='form-control') }} | {{ form.username(size=20, class='form-control') }}
@ -25,8 +18,15 @@
| {% for error in form.username.errors %}{{ error|e }}{% endfor %} | {% for error in form.username.errors %}{{ error|e }}{% endfor %}
| {% endif %} | {% endif %}
.form-group.settings-password .form-group
| {{ _("Change your password at") }} #[a(href="https://blender.org/id/change") Blender ID] label {{ _("Full name") }}
p {{ current_user.full_name }}
.form-group
label {{ _("E-mail") }}
p {{ current_user.email }}
.form-group
| {{ _("Change your full name, email, and password at") }} #[a(href="https://www.blender.org/id/settings/profile",target='_blank') Blender ID].
.right .right
.settings-avatar .settings-avatar

View File

@ -677,7 +677,7 @@ class RequireRolesTest(AbstractPillarTest):
self.assertFalse(called[0]) self.assertFalse(called[0])
with self.app.test_request_context(): with self.app.test_request_context():
self.login_api_as(ObjectId(24 * 'a'), ['admin']) self.login_api_as(ObjectId(24 * 'a'), ['demo'])
call_me() call_me()
self.assertTrue(called[0]) self.assertTrue(called[0])

View File

@ -95,7 +95,7 @@ class ProjectCreationTest(AbstractProjectTest):
def test_project_creation_access_admin(self): def test_project_creation_access_admin(self):
"""Admin-created projects should be public""" """Admin-created projects should be public"""
proj = self._create_user_and_project(roles={'admin'}) proj = self._create_user_and_project(roles={'admin', 'demo'})
self.assertEqual(['GET'], proj['permissions']['world']) self.assertEqual(['GET'], proj['permissions']['world'])
def test_project_creation_access_subscriber(self): def test_project_creation_access_subscriber(self):
@ -311,13 +311,14 @@ class ProjectEditTest(AbstractProjectTest):
def test_delete_by_admin(self): def test_delete_by_admin(self):
# Create public test project. # Create public test project.
project_info = self._create_user_and_project(['admin']) project_info = self._create_user_and_project(['admin', 'demo'])
project_id = project_info['_id'] project_id = project_info['_id']
project_url = '/api/projects/%s' % project_id project_url = '/api/projects/%s' % project_id
# Create admin user that doesn't own the project, to check that # Create admin user that doesn't own the project, to check that
# non-owner admins can delete projects too. # non-owner admins can delete projects too.
self._create_user_with_token(['admin'], 'admin-token', user_id='cafef00dbeefcafef00dbeef') self._create_user_with_token(['admin'], 'admin-token',
user_id='cafef00dbeefcafef00dbeef')
# Admin user should be able to DELETE. # Admin user should be able to DELETE.
resp = self.client.delete(project_url, resp = self.client.delete(project_url,

View File

@ -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')