diff --git a/pillar/application/modules/blender_cloud/__init__.py b/pillar/application/modules/blender_cloud/__init__.py index 62e2e88c..5bde2c86 100644 --- a/pillar/application/modules/blender_cloud/__init__.py +++ b/pillar/application/modules/blender_cloud/__init__.py @@ -1,5 +1,6 @@ def setup_app(app, url_prefix): - from . import texture_libs + from . import texture_libs, home_project texture_libs.setup_app(app, url_prefix=url_prefix) + home_project.setup_app(app, url_prefix=url_prefix) diff --git a/pillar/application/modules/blender_cloud/home_project.py b/pillar/application/modules/blender_cloud/home_project.py new file mode 100644 index 00000000..50aee78c --- /dev/null +++ b/pillar/application/modules/blender_cloud/home_project.py @@ -0,0 +1,92 @@ +import logging + +from bson import ObjectId +from eve.methods.put import put_internal +from eve.methods.get import get +from flask import Blueprint, g, current_app +from werkzeug import exceptions as wz_exceptions + +from application.modules import projects +from application import utils +from application.utils import authentication, authorization + +blueprint = Blueprint('blender_cloud.home_project', __name__) +log = logging.getLogger(__name__) + +# Users with any of these roles will get a home project. +HOME_PROJECT_USERS = {u'subscriber', u'demo'} + + +def create_home_project(user_id): + """Creates a home project for the given user.""" + + log.info('Creating home project for user %s', user_id) + project = projects.create_new_project(project_name='Home', + user_id=ObjectId(user_id), + overrides={'category': 'home'}) + + # Re-validate the authentication token, so that the put_internal call sees the + # new group created for the project. + authentication.validate_token() + + # There are a few things in the on_insert_projects hook we need to adjust. + + # Ensure that the project is private, even for admins. + project['permissions']['world'] = [] + + # Set up the correct node types. No need to set permissions for them, + # as the inherited project permissions are fine. + from manage_extra.node_types.group import node_type_group + from manage_extra.node_types.asset import node_type_asset + from manage_extra.node_types.text import node_type_text + + project['node_types'] = [ + node_type_group, + node_type_asset, + node_type_text, + ] + + result, _, _, status = put_internal('projects', utils.remove_private_keys(project), + _id=project['_id']) + if status != 200: + log.error('Unable to update home project %s for user %s: %s', + project['_id'], user_id, result) + raise wz_exceptions.InternalServerError('Unable to update home project') + project.update(result) + + return project + + +@blueprint.route('/home-project') +@authorization.require_login(require_roles={u'subscriber', u'demo'}) +def home_project(): + user_id = g.current_user['user_id'] + + roles = g.current_user.get('roles', ()) + log.debug('Possibly creating home project for user %s with roles %s', user_id, roles) + if not HOME_PROJECT_USERS.intersection(roles): + log.debug('User %s is not a subscriber, not creating home project.', user_id) + return 'No home project', 404 + + resp, _, _, status, _ = get('projects', category=u'home', user=user_id) + if status != 200: + return utils.jsonify(resp), status + + if resp['_items']: + project = resp['_items'][0] + else: + log.debug('Home project for user %s not found', user_id) + project = create_home_project(user_id) + + return utils.jsonify(project), status + + +def has_home_project(user_id): + """Returns True iff the user has a home project.""" + + proj_coll = current_app.data.driver.db['projects'] + return proj_coll.count({'user': user_id, 'category': 'home'}) > 0 + + +def setup_app(app, url_prefix): + app.register_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/application/modules/projects.py b/pillar/application/modules/projects.py index 9d01ed2e..55ffc3ca 100644 --- a/pillar/application/modules/projects.py +++ b/pillar/application/modules/projects.py @@ -194,7 +194,7 @@ def after_inserting_project(project, db_user): abort_with_error(500) -def _create_new_project(project_name, user_id, overrides): +def create_new_project(project_name, user_id, overrides): """Creates a new project owned by the given user.""" log.info('Creating new project "%s" for user %s', project_name, user_id) @@ -242,7 +242,7 @@ def create_project(overrides=None): project_name = request.form['project_name'] user_id = g.current_user['user_id'] - project = _create_new_project(project_name, user_id, overrides) + project = create_new_project(project_name, user_id, overrides) # Return the project in the response. return jsonify(project, status=201, headers={'Location': '/projects/%s' % project['_id']}) @@ -306,6 +306,7 @@ def project_manage_users(): user = users_collection.find_one({'_id': target_user_id}, {'username': 1, 'email': 1, 'full_name': 1}) + user['_status'] = 'OK' return jsonify(user) diff --git a/pillar/application/modules/service.py b/pillar/application/modules/service.py index e9f8882e..ef43f70d 100644 --- a/pillar/application/modules/service.py +++ b/pillar/application/modules/service.py @@ -7,7 +7,7 @@ from flask import Blueprint, current_app, g, request from werkzeug import exceptions as wz_exceptions from application.utils import authorization -from application.modules import local_auth +from application.modules import local_auth, users blueprint = Blueprint('service', __name__) log = logging.getLogger(__name__) diff --git a/pillar/application/modules/users.py b/pillar/application/modules/users.py index 8aac9347..54b769a6 100644 --- a/pillar/application/modules/users.py +++ b/pillar/application/modules/users.py @@ -4,7 +4,8 @@ import json import logging import urllib -from flask import g, current_app, Blueprint, make_response +from flask import g, current_app, Blueprint + from werkzeug.exceptions import Forbidden from eve.utils import parse_request from eve.methods.get import get diff --git a/pillar/manage_extra/node_types/text.py b/pillar/manage_extra/node_types/text.py new file mode 100644 index 00000000..9c3345f4 --- /dev/null +++ b/pillar/manage_extra/node_types/text.py @@ -0,0 +1,28 @@ +node_type_text = { + 'name': 'text', + 'description': 'Text', + 'parent': ['group', 'project'], + 'dyn_schema': { + 'content': { + 'type': 'string', + 'required': True, + 'minlength': 3, + 'maxlength': 90000, + }, + 'shared_slug': { + 'type': 'string', + 'required': False, + }, + 'syntax': { # for syntax highlighting + 'type': 'string', + 'required': False, + }, + 'node_expires': { + 'type': 'datetime', + 'required': False, + }, + }, + 'form_schema': { + 'shared_slug': {'visible': False}, + } +} diff --git a/pillar/settings.py b/pillar/settings.py index a27b3c4e..7596e415 100644 --- a/pillar/settings.py +++ b/pillar/settings.py @@ -569,7 +569,8 @@ projects_schema = { 'film', 'assets', 'software', - 'game' + 'game', + 'home', ], 'required': True, }, diff --git a/tests/common_test_class.py b/tests/common_test_class.py index 670c64e4..00fabdc4 100644 --- a/tests/common_test_class.py +++ b/tests/common_test_class.py @@ -106,7 +106,7 @@ class AbstractPillarTest(TestMinimal): return found['_id'], found - def create_user(self, user_id='cafef00dc379cf10c4aaceaf', roles=('subscriber', )): + def create_user(self, user_id='cafef00dc379cf10c4aaceaf', roles=('subscriber',)): from application.utils.authentication import make_unique_username with self.app.test_request_context(): @@ -141,6 +141,34 @@ class AbstractPillarTest(TestMinimal): return token_data + def badger(self, user_email, role, action, srv_token=None): + """Creates a service account, and uses it to grant or revoke a role to the user. + + To skip creation of the service account, pass a srv_token. + + :returns: the authentication token of the created service account. + :rtype: str + """ + + # Create a service account if needed. + if srv_token is None: + from application.modules.service import create_service_account + with self.app.test_request_context(): + _, srv_token_doc = create_service_account('service@example.com', + {'badger'}, + {'badger': [role]}) + srv_token = srv_token_doc['token'] + + resp = self.client.post('/service/badger', + headers={'Authorization': self.make_header(srv_token), + 'Content-Type': 'application/json'}, + data=json.dumps({'action': action, + 'role': role, + 'user_email': user_email})) + self.assertEqual(204, resp.status_code, resp.data) + + return srv_token + def mock_blenderid_validate_unhappy(self): """Sets up Responses to mock unhappy validation flow.""" diff --git a/tests/test_bcloud_home_project.py b/tests/test_bcloud_home_project.py new file mode 100644 index 00000000..2ddaf076 --- /dev/null +++ b/tests/test_bcloud_home_project.py @@ -0,0 +1,108 @@ +# -*- encoding: utf-8 -*- + +"""Unit tests for the Blender Cloud home project module.""" + +import functools +import json +import logging +import urllib + +import responses +from bson import ObjectId +from flask import g, url_for + +from common_test_class import AbstractPillarTest, TEST_EMAIL_ADDRESS + +log = logging.getLogger(__name__) + + +class HomeProjectTest(AbstractPillarTest): + + def setUp(self, **kwargs): + AbstractPillarTest.setUp(self, **kwargs) + self.create_standard_groups() + + def _create_user_with_token(self, roles, token, user_id='cafef00df00df00df00df00d'): + """Creates a user directly in MongoDB, so that it doesn't trigger any Eve hooks.""" + user_id = self.create_user(roles=roles, user_id=user_id) + self.create_valid_auth_token(user_id, token) + return user_id + + def test_create_home_project(self): + from application.modules.blender_cloud import home_project + from application.utils.authentication import validate_token + + user_id = self._create_user_with_token(roles={u'subscriber'}, token='token') + + # Test home project creation + with self.app.test_request_context(headers={'Authorization': self.make_header('token')}): + validate_token() + + proj = home_project.create_home_project(user_id) + self.assertEqual('home', proj['category']) + self.assertEqual({u'text', u'group', u'asset'}, + set(nt['name'] for nt in proj['node_types'])) + + endpoint = url_for('blender_cloud.home_project.home_project') + db_proj = self.app.data.driver.db['projects'].find_one(proj['_id']) + + # Test availability at end-point + resp = self.client.get(endpoint) + self.assertEqual(403, resp.status_code) + + resp = self.client.get(endpoint, headers={'Authorization': self.make_header('token')}) + self.assertEqual(200, resp.status_code) + + json_proj = json.loads(resp.data) + self.assertEqual(ObjectId(json_proj['_id']), proj['_id']) + self.assertEqual(json_proj['_etag'], db_proj['_etag']) + + @responses.activate + def test_autocreate_home_project_after_getting_subscriber_role(self): + from application.modules.blender_cloud import home_project + + # Implicitly create user by token validation. + self.mock_blenderid_validate_happy() + resp = self.client.get('/users/me', headers={'Authorization': self.make_header('token')}) + self.assertEqual(200, resp.status_code, resp) + + # Grant subscriber role, and fetch the home project. + self.badger(TEST_EMAIL_ADDRESS, 'subscriber', 'grant') + + resp = self.client.get('/bcloud/home-project', + headers={'Authorization': self.make_header('token')}) + self.assertEqual(200, resp.status_code) + + json_proj = json.loads(resp.data) + self.assertEqual('home', json_proj['category']) + + @responses.activate + def test_autocreate_home_project_after_getting_demo_role(self): + # Implicitly create user by token validation. + self.mock_blenderid_validate_happy() + resp = self.client.get('/users/me', headers={'Authorization': self.make_header('token')}) + self.assertEqual(200, resp.status_code, resp) + + # Grant demo role, which should also should allow creation fo the home project. + self.badger(TEST_EMAIL_ADDRESS, 'demo', 'grant') + + resp = self.client.get('/bcloud/home-project', + headers={'Authorization': self.make_header('token')}) + self.assertEqual(200, resp.status_code) + + json_proj = json.loads(resp.data) + self.assertEqual('home', json_proj['category']) + + def test_has_home_project(self): + from application.modules.blender_cloud import home_project + from application.utils.authentication import validate_token + + user_id = self._create_user_with_token(roles={u'subscriber'}, token='token') + + # Test home project creation + with self.app.test_request_context(headers={'Authorization': self.make_header('token')}): + validate_token() + + self.assertFalse(home_project.has_home_project(user_id)) + home_project.create_home_project(user_id) + self.assertTrue(home_project.has_home_project(user_id)) diff --git a/tests/test_project_management.py b/tests/test_project_management.py index fd6e9ac6..4f4ff396 100644 --- a/tests/test_project_management.py +++ b/tests/test_project_management.py @@ -138,13 +138,13 @@ class ProjectCreationTest(AbstractProjectTest): headers={'Authorization': self.make_header('token-a')}) self.assertEqual(200, resp.status_code) proj_list = json.loads(resp.data) - self.assertEqual([u'Prøject A'], [p['name'] for p in proj_list['_items']]) + self.assertEqual({u'Prøject A'}, {p['name'] for p in proj_list['_items']}) resp = self.client.get('/projects', headers={'Authorization': self.make_header('token-b')}) self.assertEqual(200, resp.status_code) proj_list = json.loads(resp.data) - self.assertEqual([u'Prøject B'], [p['name'] for p in proj_list['_items']]) + self.assertEqual({u'Prøject B'}, {p['name'] for p in proj_list['_items']}) # No access to anything for user C, should result in empty list. self._create_user_with_token(roles={u'subscriber'}, token='token-c', user_id=12 * 'c')