Merge branch 'master' of git.blender.org:pillar into elastic

This commit is contained in:
Stephan preeker 2017-12-29 12:19:47 +01:00
commit d726e15ed8
28 changed files with 529 additions and 217 deletions

View File

@ -21,7 +21,6 @@ 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
@ -42,6 +41,7 @@ import pillar.web.jinja
from . import api from . import api
from . import web from . import web
from . import auth from . import auth
from . import sentry_extra
import pillar.api.organizations import pillar.api.organizations
empty_settings = { empty_settings = {
@ -106,7 +106,7 @@ class PillarServer(BlinkerCompatibleEve):
self._config_tempdirs() self._config_tempdirs()
self._config_git() self._config_git()
self.sentry: typing.Optional[Sentry] = None self.sentry: typing.Optional[sentry_extra.PillarSentry] = None
self._config_sentry() self._config_sentry()
self._config_google_cloud_storage() self._config_google_cloud_storage()
@ -207,8 +207,9 @@ class PillarServer(BlinkerCompatibleEve):
self.sentry = None self.sentry = None
return return
self.sentry = Sentry(self, logging=True, level=logging.WARNING, self.sentry = sentry_extra.PillarSentry(
logging_exclusions=('werkzeug',)) self, logging=True, level=logging.WARNING,
logging_exclusions=('werkzeug',))
# bugsnag.before_notify(bugsnag_extra.add_pillar_request_to_notification) # bugsnag.before_notify(bugsnag_extra.add_pillar_request_to_notification)
# got_request_exception.connect(self.__notify_bugsnag) # got_request_exception.connect(self.__notify_bugsnag)
@ -424,7 +425,7 @@ class PillarServer(BlinkerCompatibleEve):
custom_jinja_loader = jinja2.ChoiceLoader(paths_list) custom_jinja_loader = jinja2.ChoiceLoader(paths_list)
self.jinja_loader = custom_jinja_loader self.jinja_loader = custom_jinja_loader
pillar.web.jinja.setup_jinja_env(self.jinja_env) pillar.web.jinja.setup_jinja_env(self.jinja_env, self.config)
# Register context processors from extensions # Register context processors from extensions
for ext in self.pillar_extensions.values(): for ext in self.pillar_extensions.values():
@ -461,6 +462,7 @@ class PillarServer(BlinkerCompatibleEve):
'pillar.celery.tasks', 'pillar.celery.tasks',
'pillar.celery.search_index_tasks', 'pillar.celery.search_index_tasks',
'pillar.celery.file_link_tasks', 'pillar.celery.file_link_tasks',
'pillar.celery.email_tasks',
] ]
# Allow Pillar extensions from defining their own Celery tasks. # Allow Pillar extensions from defining their own Celery tasks.
@ -754,7 +756,7 @@ class PillarServer(BlinkerCompatibleEve):
return 'basic ' + base64.b64encode('%s:%s' % (username, subclient_id)) return 'basic ' + base64.b64encode('%s:%s' % (username, subclient_id))
def post_internal(self, resource, payl=None, skip_validation=False): def post_internal(self, resource: str, payl=None, skip_validation=False):
"""Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810""" """Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810"""
from eve.methods.post import post_internal from eve.methods.post import post_internal
@ -763,7 +765,7 @@ class PillarServer(BlinkerCompatibleEve):
with self.__fake_request_url_rule('POST', path): with self.__fake_request_url_rule('POST', path):
return post_internal(resource, payl=payl, skip_validation=skip_validation)[:4] return post_internal(resource, payl=payl, skip_validation=skip_validation)[:4]
def put_internal(self, resource, payload=None, concurrency_check=False, def put_internal(self, resource: str, payload=None, concurrency_check=False,
skip_validation=False, **lookup): skip_validation=False, **lookup):
"""Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810""" """Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810"""
from eve.methods.put import put_internal from eve.methods.put import put_internal
@ -774,7 +776,7 @@ class PillarServer(BlinkerCompatibleEve):
return put_internal(resource, payload=payload, concurrency_check=concurrency_check, return put_internal(resource, payload=payload, concurrency_check=concurrency_check,
skip_validation=skip_validation, **lookup)[:4] skip_validation=skip_validation, **lookup)[:4]
def patch_internal(self, resource, payload=None, concurrency_check=False, def patch_internal(self, resource: str, payload=None, concurrency_check=False,
skip_validation=False, **lookup): skip_validation=False, **lookup):
"""Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810""" """Workaround for Eve issue https://github.com/nicolaiarocci/eve/issues/810"""
from eve.methods.patch import patch_internal from eve.methods.patch import patch_internal

View File

@ -1,6 +1,7 @@
import logging import logging
import typing import typing
import blinker
from flask import Blueprint, Response from flask import Blueprint, Response
import requests import requests
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
@ -21,6 +22,10 @@ ROLES_BID_TO_PILLAR = {
'cloud_has_subscription': 'has_subscription', 'cloud_has_subscription': 'has_subscription',
} }
user_subscription_updated = blinker.NamedSignal(
'user_subscription_updated',
'The sender is a UserClass instance, kwargs includes "revoke_roles" and "grant_roles".')
@blueprint.route('/update-subscription') @blueprint.route('/update-subscription')
@authorization.require_login() @authorization.require_login()
@ -31,7 +36,7 @@ def update_subscription() -> typing.Tuple[str, int]:
""" """
my_log: logging.Logger = log.getChild('update_subscription') my_log: logging.Logger = log.getChild('update_subscription')
current_user = auth.get_current_user() real_current_user = auth.get_current_user() # multiple accesses, just get unproxied.
try: try:
bid_user = blender_id.fetch_blenderid_user() bid_user = blender_id.fetch_blenderid_user()
@ -41,10 +46,10 @@ def update_subscription() -> typing.Tuple[str, int]:
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.', current_user.user_id) 'Unable to update subscription status.', real_current_user.user_id)
return '', 204 return '', 204
do_update_subscription(current_user, bid_user) do_update_subscription(real_current_user, bid_user)
return '', 204 return '', 204
@ -157,6 +162,14 @@ def do_update_subscription(local_user: auth.UserClass, bid_user: dict):
user_id, email, ', '.join(sorted(revoke_roles))) user_id, email, ', '.join(sorted(revoke_roles)))
service.do_badger('revoke', roles=revoke_roles, user_id=user_id) service.do_badger('revoke', roles=revoke_roles, user_id=user_id)
# Let the world know this user's subscription was updated.
final_roles = (plr_roles - revoke_roles).union(grant_roles)
local_user.roles = list(final_roles)
local_user.collect_capabilities()
user_subscription_updated.send(local_user,
grant_roles=grant_roles,
revoke_roles=revoke_roles)
# Re-index the user in the search database. # Re-index the user in the search database.
from pillar.api.users import hooks from pillar.api.users import hooks
hooks.push_updated_user_to_search({'_id': user_id}, {}) hooks.push_updated_user_to_search({'_id': user_id}, {})

View File

@ -376,6 +376,13 @@ class OrgManager:
return bool(org_count) return bool(org_count)
def user_is_unknown_member(self, member_email: str) -> bool:
"""Return True iff the email is an unknown member of some org."""
org_coll = current_app.db('organizations')
org_count = org_coll.count({'unknown_members': member_email})
return bool(org_count)
def setup_app(app): def setup_app(app):
from . import patch, hooks from . import patch, hooks

View File

@ -2,7 +2,6 @@ import copy
import logging import logging
from flask import request, abort, current_app from flask import request, abort, current_app
from gcloud import exceptions as gcs_exceptions
from pillar.api.node_types.asset import node_type_asset from pillar.api.node_types.asset import node_type_asset
from pillar.api.node_types.comment import node_type_comment from pillar.api.node_types.comment import node_type_comment
@ -10,7 +9,6 @@ from pillar.api.node_types.group import node_type_group
from pillar.api.node_types.group_texture import node_type_group_texture from pillar.api.node_types.group_texture import node_type_group_texture
from pillar.api.node_types.texture import node_type_texture from pillar.api.node_types.texture import node_type_texture
from pillar.api.file_storage_backends import default_storage_backend from pillar.api.file_storage_backends import default_storage_backend
from pillar.api.file_storage_backends.gcs import GoogleCloudStorageBucket
from pillar.api.utils import authorization, authentication from pillar.api.utils import authorization, authentication
from pillar.api.utils import remove_private_keys from pillar.api.utils import remove_private_keys
from pillar.api.utils.authorization import user_has_role, check_permissions from pillar.api.utils.authorization import user_has_role, check_permissions

View File

@ -198,8 +198,9 @@ def after_inserting_users(user_docs):
user_email = user_doc.get('email') user_email = user_doc.get('email')
if not user_id or not user_email: if not user_id or not user_email:
log.warning('User created with _id=%r and email=%r, unable to check organizations', # Missing emails can happen when creating a service account, it's fine.
user_id, user_email) log.info('User created with _id=%r and email=%r, unable to check organizations',
user_id, user_email)
continue continue
om.make_member_known(user_id, user_email) om.make_member_known(user_id, user_email)

View File

@ -69,19 +69,19 @@ def find_user_in_db(user_info: dict, provider='blender-id'):
users = current_app.data.driver.db['users'] users = current_app.data.driver.db['users']
user_id = user_info['id']
query = {'$or': [ query = {'$or': [
{'auth': {'$elemMatch': { {'auth': {'$elemMatch': {
'user_id': str(user_info['id']), 'user_id': str(user_id),
'provider': provider}}}, 'provider': provider}}},
{'email': user_info['email']}, {'email': user_info['email']},
]} ]}
log.debug('Querying: %s', query) log.debug('Querying: %s', query)
db_user = users.find_one(query) db_user = users.find_one(query)
if db_user: if db_user:
log.debug('User with {provider} id {user_id} already in our database, ' log.debug('User with %s id %s already in our database, updating with info from %s',
'updating with info from {provider}.'.format( provider, user_id, provider)
provider=provider, user_id=user_info['id']))
db_user['email'] = user_info['email'] db_user['email'] = user_info['email']
# Find out if an auth entry for the current provider already exists # Find out if an auth entry for the current provider already exists
@ -89,13 +89,13 @@ def find_user_in_db(user_info: dict, provider='blender-id'):
if not provider_entry: if not provider_entry:
db_user['auth'].append({ db_user['auth'].append({
'provider': provider, 'provider': provider,
'user_id': str(user_info['id']), 'user_id': str(user_id),
'token': ''}) 'token': ''})
else: else:
log.debug('User %r not yet in our database, create a new one.', user_info['id']) log.debug('User %r not yet in our database, create a new one.', user_id)
db_user = create_new_user_document( db_user = create_new_user_document(
email=user_info['email'], email=user_info['email'],
user_id=user_info['id'], user_id=user_id,
username=user_info['full_name'], username=user_info['full_name'],
provider=provider) provider=provider)
db_user['username'] = make_unique_username(user_info['email']) db_user['username'] = make_unique_username(user_info['email'])
@ -118,9 +118,13 @@ def validate_token():
from pillar.auth import AnonymousUser from pillar.auth import AnonymousUser
auth_header = request.headers.get('Authorization') or ''
if request.authorization: if request.authorization:
token = request.authorization.username token = request.authorization.username
oauth_subclient = request.authorization.password oauth_subclient = request.authorization.password
elif auth_header.startswith('Bearer '):
token = auth_header[7:].strip()
oauth_subclient = ''
else: else:
# Check the session, the user might be logged in through Flask-Login. # Check the session, the user might be logged in through Flask-Login.
from pillar import auth from pillar import auth
@ -180,7 +184,6 @@ def validate_this_token(token, oauth_subclient=None):
def remove_token(token: str): def remove_token(token: str):
"""Removes the token from the database.""" """Removes the token from the database."""
tokens_coll = current_app.db('tokens') tokens_coll = current_app.db('tokens')
token_hashed = hash_auth_token(token) token_hashed = hash_auth_token(token)
@ -261,13 +264,13 @@ def create_new_user(email, username, user_id):
def create_new_user_document(email, user_id, username, provider='blender-id', def create_new_user_document(email, user_id, username, provider='blender-id',
token=''): token='', *, full_name=''):
"""Creates a new user document, without storing it in MongoDB. The token """Creates a new user document, without storing it in MongoDB. The token
parameter is a password in case provider is "local". parameter is a password in case provider is "local".
""" """
user_data = { user_data = {
'full_name': username, 'full_name': full_name or username,
'username': username, 'username': username,
'email': email, 'email': email,
'auth': [{ 'auth': [{
@ -371,6 +374,10 @@ def upsert_user(db_user):
raise wz_exceptions.InternalServerError( raise wz_exceptions.InternalServerError(
'Non-ObjectID string found in user.groups: %s' % db_user) 'Non-ObjectID string found in user.groups: %s' % db_user)
if not db_user['full_name']:
# Blender ID doesn't need a full name, but we do.
db_user['full_name'] = db_user['username']
r = {} r = {}
for retry in range(5): for retry in range(5):
if '_id' in db_user: if '_id' in db_user:

View File

@ -49,7 +49,7 @@ class UserClass(flask_login.UserMixin):
user = cls(token) user = cls(token)
user.user_id = db_user['_id'] user.user_id = db_user.get('_id')
user.roles = db_user.get('roles') or [] user.roles = db_user.get('roles') or []
user.group_ids = db_user.get('groups') or [] user.group_ids = db_user.get('groups') or []
user.email = db_user.get('email') or '' user.email = db_user.get('email') or ''
@ -57,7 +57,7 @@ class UserClass(flask_login.UserMixin):
user.full_name = db_user.get('full_name') or '' user.full_name = db_user.get('full_name') or ''
# Derived properties # Derived properties
user.objectid = str(db_user['_id']) user.objectid = str(user.user_id or '')
user.gravatar = utils.gravatar(user.email) user.gravatar = utils.gravatar(user.email)
user.groups = [str(g) for g in user.group_ids] user.groups = [str(g) for g in user.group_ids]
user.collect_capabilities() user.collect_capabilities()

View File

@ -0,0 +1,50 @@
"""Deferred email support.
Note that this module can only be imported when an application context is
active. Best to late-import this in the functions where it's needed.
"""
from email.message import EmailMessage
from email.headerregistry import Address
import logging
import smtplib
import celery
from pillar import current_app
log = logging.getLogger(__name__)
@current_app.celery.task(bind=True, ignore_result=True, acks_late=True)
def send_email(self: celery.Task, to_name: str, to_addr: str, subject: str, text: str, html: str):
"""Send an email to a single address."""
# WARNING: when changing the signature of this function, also change the
# self.retry() call below.
cfg = current_app.config
# Construct the message
msg = EmailMessage()
msg['Subject'] = subject
msg['From'] = Address(cfg['MAIL_DEFAULT_FROM_NAME'], addr_spec=cfg['MAIL_DEFAULT_FROM_ADDR'])
msg['To'] = (Address(to_name, addr_spec=to_addr),)
msg.set_content(text)
msg.add_alternative(html, subtype='html')
# Refuse to send mail when we're testing.
if cfg['TESTING']:
log.warning('not sending mail to %s <%s> because we are TESTING', to_name, to_addr)
return
log.info('sending email to %s <%s>', to_name, to_addr)
# Send the message via local SMTP server.
try:
with smtplib.SMTP(cfg['SMTP_HOST'], cfg['SMTP_PORT'], timeout=cfg['SMTP_TIMEOUT']) as smtp:
if cfg.get('SMTP_USERNAME') and cfg.get('SMTP_PASSWORD'):
smtp.login(cfg['SMTP_USERNAME'], cfg['SMTP_PASSWORD'])
smtp.send_message(msg)
except (IOError, OSError) as ex:
log.exception('error sending email to %s <%s>, will retry later: %s',
to_name, to_addr, ex)
self.retry((to_name, to_addr, subject, text, html), countdown=cfg['MAIL_RETRY'])
else:
log.info('mail to %s <%s> successfully sent', to_name, to_addr)

View File

@ -28,7 +28,7 @@ SECRET_KEY = ''
AUTH_TOKEN_HMAC_KEY = b'' AUTH_TOKEN_HMAC_KEY = b''
# Authentication settings # Authentication settings
BLENDER_ID_ENDPOINT = 'http://blender_id:8000/' BLENDER_ID_ENDPOINT = 'http://blender-id:8000/'
PILLAR_SERVER_ENDPOINT = 'http://pillar:5001/api/' PILLAR_SERVER_ENDPOINT = 'http://pillar:5001/api/'
@ -124,7 +124,7 @@ BLENDER_ID_USER_INFO_TOKEN = '-set-in-config-local-'
# 'blender-id': { # 'blender-id': {
# 'id': 'CLOUD-OF-SNOWFLAKES-43', # 'id': 'CLOUD-OF-SNOWFLAKES-43',
# 'secret': 'thesecret', # 'secret': 'thesecret',
# 'base_url': 'http://blender_id:8000/' # 'base_url': 'http://blender-id:8000/'
# } # }
# } # }
# OAuth providers are defined in pillar.auth.oauth # OAuth providers are defined in pillar.auth.oauth
@ -238,3 +238,13 @@ DEFAULT_LOCALE = 'en_US'
# never show the site in English. # never show the site in English.
SUPPORT_ENGLISH = True SUPPORT_ENGLISH = True
# Mail options, see pillar.celery.email_tasks.
SMTP_HOST = 'localhost'
SMTP_PORT = 2525
SMTP_USERNAME = ''
SMTP_PASSWORD = ''
SMTP_TIMEOUT = 30 # timeout in seconds, https://docs.python.org/3/library/smtplib.html#smtplib.SMTP
MAIL_RETRY = 180 # in seconds, delay until trying to send an email again.
MAIL_DEFAULT_FROM_NAME = 'Blender Cloud'
MAIL_DEFAULT_FROM_ADDR = 'cloudsupport@localhost'

42
pillar/sentry_extra.py Normal file
View File

@ -0,0 +1,42 @@
from raven.contrib.flask import Sentry
from .auth import current_user
from . import current_app
class PillarSentry(Sentry):
"""Flask Sentry with Pillar support.
This is mostly for obtaining user information on API calls,
and for preventing the auth tokens to be logged as user ID.
"""
def get_user_info(self, request):
user_info = super().get_user_info(request)
# The auth token is stored as the user ID in the flask_login
# current_user object, so don't send that to Sentry.
user_info.pop('id', None)
if len(user_info) > 1:
# Sentry always includes the IP address, but when they find a
# logged-in user, they add more info. In that case we're done.
return user_info
# This is pretty much a copy-paste from Sentry, except that it uses
# pillar.auth.current_user instead.
try:
if not current_user.is_authenticated:
return user_info
except AttributeError:
# HACK: catch the attribute error thrown by flask-login is not attached
# > current_user = LocalProxy(lambda: _request_ctx_stack.top.user)
# E AttributeError: 'RequestContext' object has no attribute 'user'
return user_info
if 'SENTRY_USER_ATTRS' in current_app.config:
for attr in current_app.config['SENTRY_USER_ATTRS']:
if hasattr(current_user, attr):
user_info[attr] = getattr(current_user, attr)
return user_info

View File

@ -42,7 +42,7 @@ BLENDER_ID_USER_RESPONSE = {'status': 'success',
'user': {'email': TEST_EMAIL_ADDRESS, 'user': {'email': TEST_EMAIL_ADDRESS,
'full_name': TEST_FULL_NAME, 'full_name': TEST_FULL_NAME,
'id': ctd.BLENDER_ID_TEST_USERID}, 'id': ctd.BLENDER_ID_TEST_USERID},
'token_expires': 'Mon, 1 Jan 2018 01:02:03 GMT'} 'token_expires': 'Mon, 1 Jan 2218 01:02:03 GMT'}
class PillarTestServer(pillar.PillarServer): class PillarTestServer(pillar.PillarServer):
@ -67,20 +67,26 @@ class PillarTestServer(pillar.PillarServer):
Without this, actual Celery tasks will be created while the tests are running. Without this, actual Celery tasks will be created while the tests are running.
""" """
from celery import Celery from celery import Celery, Task
self.celery = unittest.mock.MagicMock(Celery) self.celery = unittest.mock.MagicMock(Celery)
def fake_task(*task_args, **task_kwargs): def fake_task(*task_args, bind=False, **task_kwargs):
def decorator(f): def decorator(f):
def delay(*args, **kwargs): def delay(*args, **kwargs):
return f(*args, **kwargs) if bind:
return f(decorator.sender, *args, **kwargs)
else:
return f(*args, **kwargs)
f.delay = delay f.delay = delay
f.si = unittest.mock.MagicMock() f.si = unittest.mock.MagicMock()
f.s = unittest.mock.MagicMock() f.s = unittest.mock.MagicMock()
return f return f
if bind:
decorator.sender = unittest.mock.MagicMock(Task)
return decorator return decorator
self.celery.task = fake_task self.celery.task = fake_task
@ -251,7 +257,7 @@ class AbstractPillarTest(TestMinimal):
return result.inserted_id return result.inserted_id
def create_user(self, user_id='cafef00dc379cf10c4aaceaf', roles=('subscriber',), def create_user(self, user_id='cafef00dc379cf10c4aaceaf', roles=('subscriber',),
groups=None, *, token: str = None, email: str = TEST_EMAIL_ADDRESS): groups=None, *, token: str = None, email: str = TEST_EMAIL_ADDRESS) -> ObjectId:
from pillar.api.utils.authentication import make_unique_username from pillar.api.utils.authentication import make_unique_username
import uuid import uuid

View File

@ -24,7 +24,7 @@ OAUTH_CREDENTIALS = {
'blender-id': { 'blender-id': {
'id': 'blender-id-app-id', 'id': 'blender-id-app-id',
'secret': 'blender-idsecret', 'secret': 'blender-idsecret',
'base_url': 'http://blender_id:8000/' 'base_url': 'http://blender-id:8000/'
}, },
'facebook': { 'facebook': {
'id': 'fb-app-id', 'id': 'fb-app-id',

View File

@ -1,5 +1,6 @@
"""Our custom Jinja filters and other template stuff.""" """Our custom Jinja filters and other template stuff."""
import functools
import logging import logging
import typing import typing
@ -146,7 +147,7 @@ def do_yesno(value, arg=None):
return no return no
def setup_jinja_env(jinja_env): def setup_jinja_env(jinja_env, app_config: dict):
jinja_env.filters['pretty_date'] = format_pretty_date jinja_env.filters['pretty_date'] = format_pretty_date
jinja_env.filters['pretty_date_time'] = format_pretty_date_time jinja_env.filters['pretty_date_time'] = format_pretty_date_time
jinja_env.filters['undertitle'] = format_undertitle jinja_env.filters['undertitle'] = format_undertitle
@ -157,5 +158,8 @@ def setup_jinja_env(jinja_env):
jinja_env.filters['yesno'] = do_yesno jinja_env.filters['yesno'] = do_yesno
jinja_env.filters['repr'] = repr jinja_env.filters['repr'] = repr
jinja_env.globals['url_for_node'] = do_url_for_node jinja_env.globals['url_for_node'] = do_url_for_node
jinja_env.globals['abs_url'] = functools.partial(flask.url_for,
_external=True,
_scheme=app_config['SCHEME'])
jinja_env.globals['session'] = flask.session jinja_env.globals['session'] = flask.session
jinja_env.globals['current_user'] = flask_login.current_user jinja_env.globals['current_user'] = flask_login.current_user

View File

@ -1,3 +1,4 @@
import datetime
import json import json
import logging import logging
import itertools import itertools
@ -7,6 +8,7 @@ from pillarsdk import Node
from pillarsdk import Project from pillarsdk import Project
from pillarsdk.exceptions import ResourceNotFound from pillarsdk.exceptions import ResourceNotFound
from pillarsdk.exceptions import ForbiddenAccess from pillarsdk.exceptions import ForbiddenAccess
import flask
from flask import Blueprint from flask import Blueprint
from flask import render_template from flask import render_template
from flask import request from flask import request
@ -78,6 +80,19 @@ def index():
'sort': '-_created' 'sort': '-_created'
}, api=api) }, api=api)
show_deleted_projects = request.args.get('deleted') is not None
if show_deleted_projects:
timeframe = utils.datetime_now() - datetime.timedelta(days=31)
projects_deleted = Project.all({
'where': {'user': current_user.objectid,
'category': {'$ne': 'home'},
'_deleted': True,
'_updated': {'$gt': timeframe}},
'sort': '-_created'
}, api=api)
else:
projects_deleted = {'_items': []}
projects_shared = Project.all({ projects_shared = Project.all({
'where': {'user': {'$ne': current_user.objectid}, 'where': {'user': {'$ne': current_user.objectid},
'permissions.groups.group': {'$in': current_user.groups}, 'permissions.groups.group': {'$in': current_user.groups},
@ -87,17 +102,17 @@ def index():
}, api=api) }, api=api)
# Attach project images # Attach project images
for project in projects_user['_items']: for project_list in (projects_user, projects_deleted, projects_shared):
utils.attach_project_pictures(project, api) for project in project_list['_items']:
utils.attach_project_pictures(project, api)
for project in projects_shared['_items']:
utils.attach_project_pictures(project, api)
return render_template( return render_template(
'projects/index_dashboard.html', 'projects/index_dashboard.html',
gravatar=utils.gravatar(current_user.email, size=128), gravatar=utils.gravatar(current_user.email, size=128),
projects_user=projects_user['_items'], projects_user=projects_user['_items'],
projects_deleted=projects_deleted['_items'],
projects_shared=projects_shared['_items'], projects_shared=projects_shared['_items'],
show_deleted_projects=show_deleted_projects,
api=api) api=api)
@ -274,7 +289,7 @@ def view(project_url):
header_video_file = None header_video_file = None
header_video_node = None header_video_node = None
if project.header_node and project.header_node.node_type == 'asset' and \ if project.header_node and project.header_node.node_type == 'asset' and \
project.header_node.properties.content_type == 'video': project.header_node.properties.content_type == 'video':
header_video_node = project.header_node header_video_node = project.header_node
header_video_file = utils.get_file(project.header_node.properties.file) header_video_file = utils.get_file(project.header_node.properties.file)
header_video_node.picture = utils.get_file(header_video_node.picture) header_video_node.picture = utils.get_file(header_video_node.picture)
@ -847,3 +862,37 @@ def edit_extension(project: Project, extension_name):
return ext.project_settings(project, return ext.project_settings(project,
ext_pages=find_extension_pages()) ext_pages=find_extension_pages())
@blueprint.route('/undelete', methods=['POST'])
@login_required
def undelete():
"""Undelete a deleted project.
Can only be done by the owner of the project or an admin.
"""
# This function takes an API-style approach, even though it's a web
# endpoint. Undeleting via a REST approach would mean GETting the
# deleted project, which now causes a 404 exception to bubble to the
# client.
from pillar.api.utils import mongo, remove_private_keys
from pillar.api.utils.authorization import check_permissions
project_id = request.form.get('project_id')
if not project_id:
raise wz_exceptions.BadRequest('missing project ID')
# Check that the user has PUT permissions on the project itself.
project = mongo.find_one_or_404('projects', project_id)
check_permissions('projects', project, 'PUT')
pid = project['_id']
log.info('Undeleting project %s on behalf of %s', pid, current_user.email)
r, _, _, status = current_app.put_internal('projects', remove_private_keys(project), _id=pid)
if status != 200:
log.warning('Error %d un-deleting project %s: %s', status, pid, r)
return 'Error un-deleting project', 500
resp = flask.Response('', status=204)
resp.location = flask.url_for('projects.view', project_url=project['url'])
return resp

View File

@ -5,6 +5,7 @@ from flask import abort, Blueprint, redirect, render_template, request, session,
from flask_login import login_required from flask_login import login_required
from werkzeug import exceptions as wz_exceptions from werkzeug import exceptions as wz_exceptions
from pillar import current_app
import pillar.api.blender_cloud.subscription import pillar.api.blender_cloud.subscription
import pillar.auth import pillar.auth
from pillar.api.blender_cloud.subscription import update_subscription from pillar.api.blender_cloud.subscription import update_subscription
@ -16,6 +17,7 @@ from pillar.auth.oauth import OAuthSignIn, ProviderConfigurationMissing, Provide
from pillar.web import system_util from pillar.web import system_util
from pillarsdk import exceptions as sdk_exceptions from pillarsdk import exceptions as sdk_exceptions
from pillarsdk.users import User from pillarsdk.users import User
from . import forms from . import forms
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -83,8 +85,7 @@ def oauth_callback(provider):
def login(): def login():
if request.args.get('force'): if request.args.get('force'):
log.debug('Forcing logout of user before rendering login page.') log.debug('Forcing logout of user before rendering login page.')
logout_user() pillar.auth.logout_user()
session.clear()
session['next_after_login'] = request.args.get('next') or request.referrer session['next_after_login'] = request.args.get('next') or request.referrer
return render_template('login.html') return render_template('login.html')
@ -121,9 +122,11 @@ def switch():
# Without this URL, the user will remain on the Blender ID site. We want them to come # Without this URL, the user will remain on the Blender ID site. We want them to come
# back to the Cloud after switching users. # back to the Cloud after switching users.
scheme = current_app.config.get('PREFERRED_URL_SCHEME', 'https')
next_url_after_bid_login = url_for('users.login', next_url_after_bid_login = url_for('users.login',
next=next_url_after_cloud_login, next=next_url_after_cloud_login,
force='yes', force='yes',
_scheme=scheme,
_external=True) _external=True)
return redirect(blender_id.switch_user_url(next_url=next_url_after_bid_login)) return redirect(blender_id.switch_user_url(next_url=next_url_after_bid_login))

View File

@ -115,15 +115,17 @@ def jstree_build_from_node(node):
# Get the parent node # Get the parent node
parent = None parent = None
parent_projection = {'projection': {
'name': 1,
'parent': 1,
'project': 1,
'node_type': 1,
'properties.content_type': 1,
}}
if node.parent: if node.parent:
try: try:
parent = Node.find(node.parent, { parent = Node.find(node.parent, parent_projection, api=api)
'projection': {
'name': 1,
'node_type': 1,
'parent': 1,
'properties.content_type': 1,
}}, api=api)
# Define the child node of the tree (usually an asset) # Define the child node of the tree (usually an asset)
except ResourceNotFound: except ResourceNotFound:
# If not found, we might be on the top level, in which case we skip the # If not found, we might be on the top level, in which case we skip the
@ -147,10 +149,7 @@ def jstree_build_from_node(node):
# If we have a parent # If we have a parent
if parent.parent: if parent.parent:
try: try:
parent = Node.find(parent.parent, { parent = Node.find(parent.parent, parent_projection, api=api)
'projection': {
'name': 1, 'parent': 1, 'project': 1, 'node_type': 1},
}, api=api)
except ResourceNotFound: except ResourceNotFound:
parent = None parent = None
else: else:

View File

@ -182,13 +182,13 @@ $( document ).ready(function() {
$('#item_delete').click(function(e){ $('#item_delete').click(function(e){
e.preventDefault(); e.preventDefault();
if (ProjectUtils.isProject()) { if (ProjectUtils.isProject()) {
// url = window.location.href.split('#')[0] + 'delete'; $.post(urlProjectDelete, {project_id: ProjectUtils.projectId()})
// window.location.replace(url); .done(function () {
$.post(urlProjectDelete, {project_id: ProjectUtils.projectId()}, // Redirect to the /p/ URL that shows deleted projects.
function (data) { window.location.replace('/p/?deleted=1');
// Feedback logic })
}).done(function () { .fail(function(err) {
window.location.replace('/p/'); toastr.error(xhrErrorResponseMessage(err), 'Project deletion failed');
}); });
} else { } else {
$.post(urlNodeDelete, {node_id: ProjectUtils.nodeId()}, $.post(urlNodeDelete, {node_id: ProjectUtils.nodeId()},

View File

@ -361,6 +361,20 @@ function getNotificationsLoop() {
}, 30000); }, 30000);
} }
/* Returns a more-or-less reasonable message given an error response object. */
function xhrErrorResponseMessage(err) {
if (typeof err.responseJSON == 'undefined')
return err.statusText;
if (typeof err.responseJSON._error != 'undefined' && typeof err.responseJSON._error.message != 'undefined')
return err.responseJSON._error.message;
if (typeof err.responseJSON._message != 'undefined')
return err.responseJSON._message
return err.statusText;
}
/* Notifications: Toastr Defaults */ /* Notifications: Toastr Defaults */
toastr.options.showDuration = 50; toastr.options.showDuration = 50;
toastr.options.progressBar = true; toastr.options.progressBar = true;

View File

@ -245,7 +245,13 @@
box-shadow: 1px 1px 0 rgba(black, .1) box-shadow: 1px 1px 0 rgba(black, .1)
display: flex display: flex
margin: 10px 15px margin: 10px 15px
padding: 10px 0 padding: 10px 10px
&.deleted
background-color: $color-background-light
.title
color: $color-text-dark-hint !important
&:hover &:hover
cursor: pointer cursor: pointer
@ -259,9 +265,9 @@
.projects__list-details a.title .projects__list-details a.title
color: $color-primary color: $color-primary
a.projects__list-thumbnail .projects__list-thumbnail
position: relative position: relative
margin: 0 15px margin-right: 15px
width: 50px width: 50px
height: 50px height: 50px
border-radius: 3px border-radius: 3px
@ -280,7 +286,7 @@
display: flex display: flex
flex-direction: column flex-direction: column
a.title .title
font-size: 1.2em font-size: 1.2em
padding-bottom: 2px padding-bottom: 2px
color: $color-text-dark-primary color: $color-text-dark-primary

View File

@ -1,142 +0,0 @@
| {% macro navigation_menu_user(current_user) %}
| {% if current_user.is_authenticated %}
| {% if current_user.has_role('demo') %}
| {% set subscription = 'demo' %}
| {% elif current_user.has_cap('subscriber') %}
| {% set subscription = 'subscriber' %}
| {% else %}
| {% set subscription = 'none' %}
| {% endif %}
li(class="dropdown")
a.navbar-item.dropdown-toggle(href="#", data-toggle="dropdown", title="{{ current_user.email }}")
img.gravatar(
src="{{ current_user.gravatar }}",
class="{{ subscription }}",
alt="Avatar")
.special(class="{{ subscription }}")
| {% if subscription == 'subscriber' %}
i.pi-check
| {% elif subscription == 'demo' %}
i.pi-heart-filled
| {% else %}
i.pi-attention
| {% endif %}
ul.dropdown-menu
| {% if not current_user.has_role('protected') %}
li.subscription-status(class="{{ subscription }}")
| {% if subscription == 'subscriber' %}
a.navbar-item(
href="{{url_for('settings.billing')}}"
title="View subscription info")
i.pi-grin
span Your subscription is active!
| {% elif subscription == 'demo' %}
a.navbar-item(
href="{{url_for('settings.billing')}}"
title="View subscription info")
i.pi-heart-filled
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 %}
a.navbar-item(
href="https://store.blender.org/product/membership/"
title="Renew subscription")
i.pi-unhappy
span.info Your subscription is not active.
span.renew Click here to renew.
| {% endif %}
li
a.navbar-item(
href="{{ url_for('projects.home_project') }}"
title="Home")
i.pi-home
| Home
li
a.navbar-item(
href="{{ url_for('projects.index') }}"
title="My Projects")
i.pi-star
| My Projects
| {% if current_user.has_organizations() %}
li
a.navbar-item(
href="{{ url_for('pillar.web.organizations.index') }}"
title="My Organizations")
i.pi-users
| My Organizations
| {% endif %}
li
a.navbar-item(
href="{{ url_for('settings.profile') }}"
title="Settings")
i.pi-cog
| Settings
li
a.navbar-item(
href="{{ url_for('settings.billing') }}"
title="Billing")
i.pi-credit-card
| Subscription
li.divider(role="separator")
| {% endif %}
li
a.navbar-item(
href="{{ url_for('users.logout') }}")
i.pi-log-out(title="Log Out")
| Log out
a.navbar-item.subitem(
href="{{ url_for('users.switch') }}")
i.pi-blank
| Not {{ current_user.full_name }}?
| {% else %}
li.nav-item-sign-in
a.navbar-item(href="{{ url_for('users.login') }}")
| Log in
| {% endif %}
| {% endmacro %}
| {% macro navigation_menu_notifications(current_user) %}
| {% if current_user.is_authenticated %}
li.nav-notifications
a.navbar-item#notifications-toggle(
title="Notifications",
data-toggle="tooltip",
data-placement="bottom")
i.pi-notifications-none.nav-notifications-icon
span#notifications-count
span
.flyout-hat
#notifications.flyout.notifications
.flyout-content
span.flyout-title Notifications
a#notifications-markallread(
title="Mark All as Read",
href="/notifications/read-all")
| Mark All as Read
| {% include '_notifications.html' %}
| {% endif %}
| {% endmacro %}

View File

@ -0,0 +1,23 @@
| {% if current_user.is_authenticated %}
li.nav-notifications
a.navbar-item#notifications-toggle(
title="Notifications",
data-toggle="tooltip",
data-placement="bottom")
i.pi-notifications-none.nav-notifications-icon
span#notifications-count
span
.flyout-hat
#notifications.flyout.notifications
.flyout-content
span.flyout-title Notifications
a#notifications-markallread(
title="Mark All as Read",
href="/notifications/read-all")
| Mark All as Read
| {% include '_notifications.html' %}
| {% endif %}

View File

@ -0,0 +1 @@
| {% extends 'menus/user_base.html' %}

View File

@ -0,0 +1,66 @@
| {% block menu_body %}
| {% if current_user.is_authenticated %}
li(class="dropdown")
| {% block menu_avatar %}
a.navbar-item.dropdown-toggle(href="#", data-toggle="dropdown", title="{{ current_user.email }}")
img.gravatar(
src="{{ current_user.gravatar }}",
alt="Avatar")
| {% endblock menu_avatar %}
ul.dropdown-menu
| {% if not current_user.has_role('protected') %}
| {% block menu_list %}
li
a.navbar-item(
href="{{ url_for('projects.home_project') }}"
title="Home")
i.pi-home
| Home
li
a.navbar-item(
href="{{ url_for('projects.index') }}"
title="My Projects")
i.pi-star
| My Projects
| {% if current_user.has_organizations() %}
li
a.navbar-item(
href="{{ url_for('pillar.web.organizations.index') }}"
title="My Organizations")
i.pi-users
| My Organizations
| {% endif %}
li
a.navbar-item(
href="{{ url_for('settings.profile') }}"
title="Settings")
i.pi-cog
| Settings
| {% endblock menu_list %}
li.divider(role="separator")
| {% endif %}
li
a.navbar-item(
href="{{ url_for('users.logout') }}")
i.pi-log-out(title="Log Out")
| Log out
a.navbar-item.subitem(
href="{{ url_for('users.switch') }}")
i.pi-blank
| Not {{ current_user.full_name }}?
| {% else %}
li.nav-item-sign-in
a.navbar-item(href="{{ url_for('users.login') }}")
| Log in
| {% endif %}
| {% endblock menu_body %}

View File

@ -18,6 +18,25 @@ meta(name="twitter:image", content="{{ url_for('static', filename='assets/img/ba
| {{current_user.full_name}} | {{current_user.full_name}}
| {% endblock %} | {% endblock %}
| {% block css %}
| {{ super() }}
style.
.deleted-projects-toggle {
z-index: 10;
position: absolute;
right: 0;
font-size: 20px;
padding: 3px;
text-shadow: 0 0 2px white;
}
.deleted-projects-toggle .show-deleted {
color: #aaa;
}
.deleted-projects-toggle .hide-deleted {
color: #bbb;
}
| {% endblock %}
| {% block body %} | {% block body %}
.dashboard-container .dashboard-container
section.dashboard-main section.dashboard-main
@ -54,7 +73,36 @@ meta(name="twitter:image", content="{{ url_for('static', filename='assets/img/ba
| {% endif %} | {% endif %}
nav.nav-tabs__tab.active#own_projects nav.nav-tabs__tab.active#own_projects
.deleted-projects-toggle
| {% if show_deleted_projects %}
a.hide-deleted(href="{{ request.base_url }}", title='Hide deleted projects')
i.pi-trash
| {% else %}
a.show-deleted(href="{{ request.base_url }}?deleted=1", title='Show deleted projects')
i.pi-trash
| {% endif %}
ul.projects__list ul.projects__list
| {% for project in projects_deleted %}
li.projects__list-item.deleted
span.projects__list-thumbnail
| {% if project.picture_square %}
img(src="{{ project.picture_square.thumbnail('s', api=api) }}")
| {% else %}
i.pi-blender-cloud
| {% endif %}
.projects__list-details
span.title {{ project.name }}
ul.meta
li.status.deleted Deleted
li.edit
a(href="javascript:undelete_project('{{ project._id }}')") Restore project
| {% else %}
| {% if show_deleted_projects %}
li.projects__list-item.deleted You have no recenly deleted projects. Deleted projects can be restored within a month after deletion.
| {% endif %}
| {% endfor %}
| {% for project in projects_user %} | {% for project in projects_user %}
li.projects__list-item( li.projects__list-item(
data-url="{{ url_for('projects.view', project_url=project.url) }}") data-url="{{ url_for('projects.view', project_url=project.url) }}")
@ -105,7 +153,7 @@ meta(name="twitter:image", content="{{ url_for('static', filename='assets/img/ba
| {% endif %} | {% endif %}
| {% endfor %} | {% endfor %}
section.nav-tabs__tab#shared section.nav-tabs__tab#shared(style='display: none')
ul.projects__list ul.projects__list
| {% if projects_shared %} | {% if projects_shared %}
| {% for project in projects_shared %} | {% for project in projects_shared %}
@ -264,7 +312,7 @@ script.
$projects_list.find('span.user-remove-confirm').on('click', function(e){ $projects_list.find('span.user-remove-confirm').on('click', function(e){
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
var parent = $(this).closest('projects__list-item'); var parent = $(this).closest('.projects__list-item');
function removeUser(userId, projectUrl){ function removeUser(userId, projectUrl){
$.post(projectUrl, {user_id: userId, action: 'remove'}) $.post(projectUrl, {user_id: userId, action: 'remove'})
@ -278,4 +326,15 @@ script.
hopToTop(); // Display jump to top button hopToTop(); // Display jump to top button
}); });
function undelete_project(project_id) {
console.log('undeleting project', project_id);
$.post('{{ url_for('projects.undelete') }}', {project_id: project_id})
.done(function(data, textStatus, jqXHR) {
location.href = jqXHR.getResponseHeader('Location');
})
.fail(function(err) {
toastr.error(xhrErrorResponseMessage(err), 'Undeletion failed');
})
}
| {% endblock %} | {% endblock %}

View File

@ -222,7 +222,7 @@ link(href="{{ url_for('static_pillar', filename='assets/css/project-main.css', v
li.button-delete li.button-delete
a#item_delete( a#item_delete(
href="javascript:void(0);", href="javascript:void(0);",
title="Delete (Warning: no undo)", title="Can be undone within a month",
data-toggle="tooltip", data-toggle="tooltip",
data-placement="left") data-placement="left")
i.pi-trash i.pi-trash

View File

@ -717,6 +717,74 @@ class UserCreationTest(AbstractPillarTest):
db_user = users_coll.find()[0] db_user = users_coll.find()[0]
self.assertEqual(db_user['email'], TEST_EMAIL_ADDRESS) self.assertEqual(db_user['email'], TEST_EMAIL_ADDRESS)
@responses.activate
def test_create_by_auth_no_full_name(self):
"""Blender ID does not require full name, we do."""
with self.app.test_request_context():
users_coll = self.app.db().users
self.assertEqual(0, users_coll.count())
bid_resp = {'status': 'success',
'user': {'email': TEST_EMAIL_ADDRESS,
'full_name': '',
'id': ctd.BLENDER_ID_TEST_USERID},
'token_expires': 'Mon, 1 Jan 2218 01:02:03 GMT'}
responses.add(responses.POST,
'%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
json=bid_resp,
status=200)
token = 'this is my life now'
self.get('/api/users/me', auth_token=token)
with self.app.test_request_context():
users_coll = self.app.db().users
self.assertEqual(1, users_coll.count())
db_user = users_coll.find()[0]
self.assertEqual(db_user['email'], TEST_EMAIL_ADDRESS)
self.assertNotEqual('', db_user['full_name'])
@responses.activate
def test_update_by_auth_no_full_name(self):
"""Blender ID does not require full name, we do."""
self.enter_app_context()
users_coll = self.app.db().users
self.assertEqual(0, users_coll.count())
# First request will create the user, the 2nd request will update.
self.mock_blenderid_validate_happy()
bid_resp = {'status': 'success',
'user': {'email': TEST_EMAIL_ADDRESS,
'full_name': '',
'id': ctd.BLENDER_ID_TEST_USERID},
'token_expires': 'Mon, 1 Jan 2218 01:02:03 GMT'}
responses.add(responses.POST,
'%s/u/validate_token' % self.app.config['BLENDER_ID_ENDPOINT'],
json=bid_resp,
status=200)
token = 'this is my life now'
self.get('/api/users/me', auth_token=token)
# Clear out the full name of the user. This could happen for some
# reason, and it shouldn't break the login flow.
users_coll.update_many({}, {'$set': {'full_name': ''}})
# Delete all tokens to force a re-check with Blender ID
tokens_coll = self.app.db('tokens')
tokens_coll.delete_many({})
self.get('/api/users/me', auth_token=token)
self.assertEqual(1, users_coll.count())
db_user = users_coll.find()[0]
self.assertEqual(db_user['email'], TEST_EMAIL_ADDRESS)
self.assertNotEqual('', db_user['full_name'])
def test_user_without_email_address(self): def test_user_without_email_address(self):
"""Regular users should always have an email address. """Regular users should always have an email address.

View File

@ -12,7 +12,7 @@ class OAuthTests(AbstractPillarTest):
oauth_provider = OAuthSignIn.get_provider('blender-id') oauth_provider = OAuthSignIn.get_provider('blender-id')
self.assertIsInstance(oauth_provider, BlenderIdSignIn) self.assertIsInstance(oauth_provider, BlenderIdSignIn)
self.assertEqual(oauth_provider.service.base_url, 'http://blender_id:8000/api/') self.assertEqual(oauth_provider.service.base_url, 'http://blender-id:8000/api/')
def test_provider_not_implemented(self): def test_provider_not_implemented(self):
from pillar.auth.oauth import OAuthSignIn, ProviderNotImplemented from pillar.auth.oauth import OAuthSignIn, ProviderNotImplemented
@ -46,11 +46,11 @@ class OAuthTests(AbstractPillarTest):
def test_provider_callback_happy(self): def test_provider_callback_happy(self):
from pillar.auth.oauth import OAuthSignIn from pillar.auth.oauth import OAuthSignIn
responses.add(responses.POST, 'http://blender_id:8000/oauth/token', responses.add(responses.POST, 'http://blender-id:8000/oauth/token',
json={'access_token': 'successful-token'}, json={'access_token': 'successful-token'},
status=200) status=200)
responses.add(responses.GET, 'http://blender_id:8000/api/user', responses.add(responses.GET, 'http://blender-id:8000/api/user',
json={'id': '7', json={'id': '7',
'email': 'harry@blender.org'}, 'email': 'harry@blender.org'},
status=200) status=200)

View File

@ -17,6 +17,13 @@ class RoleUpdatingTest(AbstractPillarTest):
with self.app.test_request_context(): with self.app.test_request_context():
self.create_standard_groups() self.create_standard_groups()
from pillar.api.blender_cloud import subscription as sub
self.user_subs_signal_calls = []
sub.user_subscription_updated.connect(self._user_subs_signal)
def _user_subs_signal(self, sender, **kwargs):
self.user_subs_signal_calls.append((sender, kwargs))
def _setup_testcase(self, mocked_fetch_blenderid_user, *, def _setup_testcase(self, mocked_fetch_blenderid_user, *,
bid_roles: typing.Set[str]): bid_roles: typing.Set[str]):
import urllib.parse import urllib.parse
@ -54,6 +61,12 @@ class RoleUpdatingTest(AbstractPillarTest):
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', 'has_subscription'}, set(user_info['roles'])) self.assertEqual({'subscriber', 'has_subscription'}, set(user_info['roles']))
# Check the signals
self.assertEqual(1, len(self.user_subs_signal_calls))
sender, kwargs = self.user_subs_signal_calls[0]
self.assertEqual({'revoke_roles': set(), 'grant_roles': {'subscriber', 'has_subscription'}},
kwargs)
@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):
@ -61,9 +74,9 @@ class RoleUpdatingTest(AbstractPillarTest):
bid_roles={'conference_speaker'}) bid_roles={'conference_speaker'})
# 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', 'has_subscription'}, token='my-happy-token')
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']))
# And after updating, it shouldn't be. # And after updating, it shouldn't be.
self.get('/api/bcloud/update-subscription', auth_token='my-happy-token', self.get('/api/bcloud/update-subscription', auth_token='my-happy-token',
@ -71,6 +84,11 @@ class RoleUpdatingTest(AbstractPillarTest):
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([], user_info['roles']) self.assertEqual([], user_info['roles'])
self.assertEqual(1, len(self.user_subs_signal_calls))
sender, kwargs = self.user_subs_signal_calls[0]
self.assertEqual({'revoke_roles': {'subscriber', 'has_subscription'}, 'grant_roles': set()},
kwargs)
@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_grant_demo(self, mocked_fetch_blenderid_user): def test_bid_api_grant_demo(self, mocked_fetch_blenderid_user):
@ -83,6 +101,10 @@ class RoleUpdatingTest(AbstractPillarTest):
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(['demo'], user_info['roles']) self.assertEqual(['demo'], user_info['roles'])
self.assertEqual(1, len(self.user_subs_signal_calls))
sender, kwargs = self.user_subs_signal_calls[0]
self.assertEqual({'revoke_roles': set(), 'grant_roles': {'demo'}}, kwargs)
@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_demo(self, mocked_fetch_blenderid_user): def test_bid_api_role_revoke_demo(self, mocked_fetch_blenderid_user):
@ -99,3 +121,7 @@ class RoleUpdatingTest(AbstractPillarTest):
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([], user_info['roles']) self.assertEqual([], user_info['roles'])
self.assertEqual(1, len(self.user_subs_signal_calls))
sender, kwargs = self.user_subs_signal_calls[0]
self.assertEqual({'revoke_roles': {'demo'}, 'grant_roles': set()}, kwargs)