import json import logging import itertools from pillarsdk import Node from pillarsdk import Project from pillarsdk.exceptions import ResourceNotFound from pillarsdk.exceptions import ForbiddenAccess from flask import Blueprint from flask import render_template from flask import request from flask import jsonify from flask import session from flask import abort from flask import redirect from flask import url_for from flask_login import login_required, current_user import werkzeug.exceptions as wz_exceptions from pillar import current_app from pillar.web import system_util from pillar.web import utils from pillar.web.utils.jstree import jstree_get_children from .forms import ProjectForm from .forms import NodeTypeForm blueprint = Blueprint('projects', __name__) log = logging.getLogger(__name__) SYNC_GROUP_NODE_NAME = 'Blender Sync' IMAGE_SHARING_GROUP_NODE_NAME = 'Image sharing' @blueprint.route('/') @login_required def index(): api = system_util.pillar_api() # Get all projects, except the home project. projects_user = Project.all({ 'where': {'user': current_user.objectid, 'category': {'$ne': 'home'}}, 'sort': '-_created' }, api=api) projects_shared = Project.all({ 'where': {'user': {'$ne': current_user.objectid}, 'permissions.groups.group': {'$in': current_user.groups}, 'is_private': True}, 'sort': '-_created', 'embedded': {'user': 1}, }, api=api) # Attach project images for project in projects_user['_items']: utils.attach_project_pictures(project, api) for project in projects_shared['_items']: utils.attach_project_pictures(project, api) return render_template( 'projects/index_dashboard.html', gravatar=utils.gravatar(current_user.email, size=128), projects_user=projects_user['_items'], projects_shared=projects_shared['_items'], api=api) @blueprint.route('//jstree') def jstree(project_url): """Entry point to view a project as JSTree""" api = system_util.pillar_api() try: project = Project.find_one({ 'projection': {'_id': 1}, 'where': {'url': project_url} }, api=api) except ResourceNotFound: raise wz_exceptions.NotFound('No such project') return jsonify(items=jstree_get_children(None, project._id)) @blueprint.route('/home/') @login_required def home_project(): api = system_util.pillar_api() project = _home_project(api) # Get the synchronised Blender versions project_id = project['_id'] synced_versions = synced_blender_versions(project_id, api) extra_context = { 'synced_versions': synced_versions, 'show_addon_download_buttons': True, } return render_project(project, api, extra_context) @blueprint.route('/home/images') @login_required def home_project_shared_images(): api = system_util.pillar_api() project = _home_project(api) # Get the shared images project_id = project['_id'] image_nodes = shared_image_nodes(project_id, api) extra_context = { 'shared_images': image_nodes, 'show_addon_download_buttons': current_user.has_role('subscriber', 'demo'), } return render_project(project, api, extra_context, template_name='projects/home_images.html') def _home_project(api): try: project = Project.find_from_endpoint('/bcloud/home-project', api=api) except ResourceNotFound: log.warning('Home project for user %s not found', current_user.objectid) raise wz_exceptions.NotFound('No such project') return project def synced_blender_versions(home_project_id, api): """Returns a list of Blender versions with synced settings. Returns a list of {'version': '2.77', 'date': datetime.datetime()} dicts. Returns an empty list if no Blender versions were synced. """ sync_group = Node.find_first({ 'where': {'project': home_project_id, 'node_type': 'group', 'parent': None, 'name': SYNC_GROUP_NODE_NAME}, 'projection': {'_id': 1}}, api=api) if not sync_group: return [] sync_nodes = Node.all({ 'where': {'project': home_project_id, 'node_type': 'group', 'parent': sync_group['_id']}, 'projection': { 'name': 1, '_updated': 1, }}, api=api) sync_nodes = sync_nodes._items if not sync_nodes: return [] return [{'version': node.name, 'date': node._updated} for node in sync_nodes] def shared_image_nodes(home_project_id, api): """Returns a list of pillarsdk.Node objects.""" parent_group = Node.find_first({ 'where': {'project': home_project_id, 'node_type': 'group', 'parent': None, 'name': IMAGE_SHARING_GROUP_NODE_NAME}, 'projection': {'_id': 1}}, api=api) if not parent_group: log.debug('No image sharing parent node found.') return [] nodes = Node.all({ 'where': {'project': home_project_id, 'node_type': 'asset', 'properties.content_type': 'image', 'parent': parent_group['_id']}, 'sort': '-_created', 'projection': { '_created': 1, 'name': 1, 'picture': 1, 'short_code': 1, }}, api=api) nodes = nodes._items or [] for node in nodes: node.picture = utils.get_file(node.picture) return nodes @blueprint.route('/home/jstree') def home_jstree(): """Entry point to view the home project as JSTree""" api = system_util.pillar_api() try: project = Project.find_from_endpoint('/bcloud/home-project', params={'projection': { '_id': 1, 'permissions': 1, 'category': 1, 'user': 1}}, api=api) except ResourceNotFound: raise wz_exceptions.NotFound('No such project') return jsonify(items=jstree_get_children(None, project._id)) @blueprint.route('//') def view(project_url): """Entry point to view a project""" if request.args.get('format') == 'jstree': log.warning('projects.view(%r) endpoint called with format=jstree, ' 'redirecting to proper endpoint. URL is %s; referrer is %s', project_url, request.url, request.referrer) return redirect(url_for('projects.jstree', project_url=project_url)) api = system_util.pillar_api() project = find_project_or_404(project_url, embedded={'header_node': 1}, api=api) # Load the header video file, if there is any. header_video_file = None header_video_node = None if project.header_node and project.header_node.node_type == 'asset' and \ project.header_node.properties.content_type == 'video': header_video_node = project.header_node header_video_file = utils.get_file(project.header_node.properties.file) header_video_node.picture = utils.get_file(header_video_node.picture) return render_project(project, api, extra_context={'header_video_file': header_video_file, 'header_video_node': header_video_node}) def render_project(project, api, extra_context=None, template_name=None): project.picture_square = utils.get_file(project.picture_square, api=api) project.picture_header = utils.get_file(project.picture_header, api=api) def load_latest(list_of_ids, node_type=None): """Loads a list of IDs in reversed order.""" if not list_of_ids: return [] # Construct query parameters outside the loop. projection = {'name': 1, 'user': 1, 'node_type': 1, 'project': 1, 'properties.url': 1, 'properties.content_type': 1, 'picture': 1} params = {'projection': projection, 'embedded': {'user': 1}} if node_type == 'post': projection['properties.content'] = 1 elif node_type == 'asset': projection['description'] = 1 list_latest = [] for node_id in reversed(list_of_ids or ()): try: node_item = Node.find(node_id, params, api=api) node_item.picture = utils.get_file(node_item.picture, api=api) list_latest.append(node_item) except ForbiddenAccess: pass except ResourceNotFound: log.warning('Project %s refers to removed node %s!', project._id, node_id) return list_latest project.nodes_featured = load_latest(project.nodes_featured, node_type='asset') project.nodes_blog = load_latest(project.nodes_blog, node_type='post') # Merge featured assets and blog posts into one activity stream def sort_key(item): return item._created activities = itertools.chain(project.nodes_featured, project.nodes_blog) activity_stream = sorted(activities, key=sort_key, reverse=True) if extra_context is None: extra_context = {} if project.category == 'home' and not current_app.config['RENDER_HOME_AS_REGULAR_PROJECT']: template_name = template_name or 'projects/home_index.html' return render_template( template_name, gravatar=utils.gravatar(current_user.email, size=128), project=project, api=system_util.pillar_api(), **extra_context) if template_name is None: if request.args.get('embed'): embed_string = '_embed' else: embed_string = '' template_name = "projects/view{0}.html".format(embed_string) extension_sidebar_links = current_app.extension_sidebar_links(project) return render_template(template_name, api=api, project=project, node=None, show_node=False, show_project=True, og_picture=project.picture_header, activity_stream=activity_stream, extension_sidebar_links=extension_sidebar_links, **extra_context) def render_node_page(project_url, page_url, api): """Custom behaviour for pages, which are nodes, but accessible on a custom route base. """ # TODO: ensure this is not called for the home project, as it would # generate conflicting websites project = find_project_or_404(project_url, api=api) try: page = Node.find_one({ 'where': { 'project': project['_id'], 'node_type': 'page', 'properties.url': page_url}}, api=api) except ResourceNotFound: raise wz_exceptions.NotFound('No such node') return project, page @blueprint.route('//') def view_node(project_url, node_id): """Entry point to view a node in the context of a project""" # Some browsers mangle URLs and URL-encode /p/{p-url}/#node-id if node_id.startswith('#'): return redirect(url_for('projects.view_node', project_url=project_url, node_id=node_id[1:]), code=301) # permanent redirect theatre_mode = 't' in request.args api = system_util.pillar_api() # First we check if it's a simple string, in which case we are looking for # a static page. Maybe we could use bson.objectid.ObjectId.is_valid(node_id) if not utils.is_valid_id(node_id): # raise wz_exceptions.NotFound('No such node') project, node = render_node_page(project_url, node_id, api) else: # Fetch the node before the project. If this user has access to the # node, we should be able to get the project URL too. try: node = Node.find(node_id, api=api) except ForbiddenAccess: return render_template('errors/403.html'), 403 except ResourceNotFound: raise wz_exceptions.NotFound('No such node') try: project = Project.find_one({'where': {"url": project_url, '_id': node.project}}, api=api) except ResourceNotFound: # In theatre mode, we don't need access to the project at all. if theatre_mode: project = None else: raise wz_exceptions.NotFound('No such project') og_picture = node.picture = utils.get_file(node.picture, api=api) if project: if not node.picture: og_picture = utils.get_file(project.picture_header, api=api) project.picture_square = utils.get_file(project.picture_square, api=api) # Append _theatre to load the proper template theatre = '_theatre' if theatre_mode else '' extension_sidebar_links = current_app.extension_sidebar_links(project) return render_template('projects/view{}.html'.format(theatre), api=api, project=project, node=node, show_node=True, show_project=False, og_picture=og_picture, extension_sidebar_links=extension_sidebar_links) def find_project_or_404(project_url, embedded=None, api=None): """Aborts with a NotFound exception when the project cannot be found.""" params = {'where': {"url": project_url}} if embedded: params['embedded'] = embedded try: project = Project.find_one(params, api=api) except ResourceNotFound: raise wz_exceptions.NotFound('No such project') return project @blueprint.route('//search') def search(project_url): """Search into a project""" api = system_util.pillar_api() project = find_project_or_404(project_url, api=api) project.picture_square = utils.get_file(project.picture_square, api=api) project.picture_header = utils.get_file(project.picture_header, api=api) return render_template('nodes/search.html', project=project, og_picture=project.picture_header) @blueprint.route('//edit', methods=['GET', 'POST']) @login_required def edit(project_url): api = system_util.pillar_api() # Fetch the Node or 404 try: project = Project.find_one({'where': {'url': project_url}}, api=api) # project = Project.find(project_url, api=api) except ResourceNotFound: abort(404) utils.attach_project_pictures(project, api) form = ProjectForm( project_id=project._id, name=project.name, url=project.url, summary=project.summary, description=project.description, is_private='GET' not in project.permissions.world, category=project.category, status=project.status, ) if form.validate_on_submit(): project = Project.find(project._id, api=api) project.name = form.name.data project.url = form.url.data project.summary = form.summary.data project.description = form.description.data project.category = form.category.data project.status = form.status.data if form.picture_square.data: project.picture_square = form.picture_square.data if form.picture_header.data: project.picture_header = form.picture_header.data # Update world permissions from is_private checkbox if form.is_private.data: project.permissions.world = [] else: project.permissions.world = ['GET'] project.update(api=api) # Reattach the pictures utils.attach_project_pictures(project, api) else: if project.picture_square: form.picture_square.data = project.picture_square._id if project.picture_header: form.picture_header.data = project.picture_header._id # List of fields from the form that should be hidden to regular users if current_user.has_role('admin'): hidden_fields = [] else: hidden_fields = ['url', 'status', 'is_private', 'category'] return render_template('projects/edit.html', form=form, hidden_fields=hidden_fields, project=project, api=api) @blueprint.route('//edit/node-type') @login_required def edit_node_types(project_url): api = system_util.pillar_api() # Fetch the project or 404 try: project = Project.find_one({ 'where': '{"url" : "%s"}' % (project_url)}, api=api) except ResourceNotFound: return abort(404) utils.attach_project_pictures(project, api) return render_template('projects/edit_node_types.html', api=api, project=project) @blueprint.route('//e/node-type/', methods=['GET', 'POST']) @login_required def edit_node_type(project_url, node_type_name): api = system_util.pillar_api() # Fetch the Node or 404 try: project = Project.find_one({ 'where': '{"url" : "%s"}' % (project_url)}, api=api) except ResourceNotFound: return abort(404) utils.attach_project_pictures(project, api) node_type = project.get_node_type(node_type_name) form = NodeTypeForm() if form.validate_on_submit(): # Update dynamic & form schemas dyn_schema = json.loads(form.dyn_schema.data) node_type.dyn_schema = dyn_schema form_schema = json.loads(form.form_schema.data) node_type.form_schema = form_schema # Update permissions permissions = json.loads(form.permissions.data) node_type.permissions = permissions project.update(api=api) elif request.method == 'GET': form.project_id.data = project._id if node_type: form.name.data = node_type.name form.description.data = node_type.description form.parent.data = node_type.parent dyn_schema = node_type.dyn_schema.to_dict() form_schema = node_type.form_schema.to_dict() if 'permissions' in node_type: permissions = node_type.permissions.to_dict() else: permissions = {} form.form_schema.data = json.dumps(form_schema, indent=4) form.dyn_schema.data = json.dumps(dyn_schema, indent=4) form.permissions.data = json.dumps(permissions, indent=4) return render_template('projects/edit_node_type.html', form=form, project=project, api=api, node_type=node_type) @blueprint.route('//edit/sharing', methods=['GET', 'POST']) @login_required def sharing(project_url): api = system_util.pillar_api() # Fetch the project or 404 try: project = Project.find_one({ 'where': '{"url" : "%s"}' % (project_url)}, api=api) except ResourceNotFound: return abort(404) # Fetch users that are part of the admin group users = project.get_users(api=api) for user in users['_items']: user['avatar'] = utils.gravatar(user['email']) if request.method == 'POST': user_id = request.form['user_id'] action = request.form['action'] try: if action == 'add': user = project.add_user(user_id, api=api) elif action == 'remove': user = project.remove_user(user_id, api=api) except ResourceNotFound: log.info('/p/%s/edit/sharing: User %s not found', project_url, user_id) return jsonify({'_status': 'ERROR', 'message': 'User %s not found' % user_id}), 404 # Add gravatar to user user['avatar'] = utils.gravatar(user['email']) return jsonify(user) utils.attach_project_pictures(project, api) return render_template('projects/sharing.html', api=api, project=project, users=users['_items']) @blueprint.route('/e/add-featured-node', methods=['POST']) @login_required def add_featured_node(): """Feature a node in a project. This method belongs here, because it affects the project node itself, not the asset. """ api = system_util.pillar_api() node = Node.find(request.form['node_id'], api=api) action = project_update_nodes_list(node, project_id=node.project, list_name='featured') return jsonify(status='success', data=dict(action=action)) @blueprint.route('/e/move-node', methods=['POST']) @login_required def move_node(): """Move a node within a project. While this affects the node.parent prop, we keep it in the scope of the project.""" node_id = request.form['node_id'] dest_parent_node_id = request.form.get('dest_parent_node_id') api = system_util.pillar_api() node = Node.find(node_id, api=api) # Get original parent id for clearing template fragment on success previous_parent_id = node.parent if dest_parent_node_id: node.parent = dest_parent_node_id elif node.parent: node.parent = None node.update(api=api) return jsonify(status='success', data=dict(message='node moved')) @blueprint.route('/e/delete-node', methods=['POST']) @login_required def delete_node(): """Delete a node""" api = system_util.pillar_api() node = Node.find(request.form['node_id'], api=api) if not node.has_method('DELETE'): return abort(403) node.delete(api=api) return jsonify(status='success', data=dict(message='Node deleted')) @blueprint.route('/e/toggle-node-public', methods=['POST']) @login_required def toggle_node_public(): """Give a node GET permissions for the world. Later on this can turn into a more powerful permission management function. """ api = system_util.pillar_api() node = Node.find(request.form['node_id'], api=api) if node.has_method('PUT'): if node.permissions and 'world' in node.permissions.to_dict(): node.permissions = {} message = "Node is not public anymore." else: node.permissions = dict(world=['GET']) message = "Node is now public!" node.update(api=api) return jsonify(status='success', data=dict(message=message)) else: return abort(403) @blueprint.route('/e/toggle-node-project-header', methods=['POST']) @login_required def toggle_node_project_header(): """Sets this node as the project header, or removes it if already there. """ api = system_util.pillar_api() node_id = request.form['node_id'] try: node = Node.find(node_id, {'projection': {'project': 1}}, api=api) except ResourceNotFound: log.info('User %s trying to toggle non-existing node %s as project header', current_user.objectid, node_id) return jsonify(_status='ERROR', message='Node not found'), 404 try: project = Project.find(node.project, api=api) except ResourceNotFound: log.info('User %s trying to toggle node %s as project header, but project %s not found', current_user.objectid, node_id, node.project) return jsonify(_status='ERROR', message='Project not found'), 404 # Toggle header node if project.header_node == node_id: log.debug('Un-setting header node of project %s', node.project) project.header_node = None action = 'unset' else: log.debug('Setting node %s as header of project %s', node_id, node.project) project.header_node = node_id action = 'set' # Save the project project.update(api=api) return jsonify({'_status': 'OK', 'action': action}) def project_update_nodes_list(node, project_id=None, list_name='latest'): """Update the project node with the latest edited or favorited node. The list value can be 'latest' or 'featured' and it will determined where the node reference will be placed in. """ if node.properties.status and node.properties.status == 'published': if not project_id and 'current_project_id' in session: project_id = session['current_project_id'] elif not project_id: return None project_id = node.project if type(project_id) is not str: project_id = node.project._id api = system_util.pillar_api() project = Project.find(project_id, api=api) if list_name == 'latest': nodes_list = project.nodes_latest elif list_name == 'blog': nodes_list = project.nodes_blog else: nodes_list = project.nodes_featured if not nodes_list: node_list_name = 'nodes_' + list_name project[node_list_name] = [] nodes_list = project[node_list_name] elif len(nodes_list) > 15: nodes_list.pop(0) if node._id in nodes_list: # Pop to put this back on top of the list nodes_list.remove(node._id) if list_name == 'featured': # We treat the action as a toggle and do not att the item back project.update(api=api) return "removed" nodes_list.append(node._id) project.update(api=api) return "added" @blueprint.route('/create') @login_required def create(): """Create a new project. This is a multi step operation that involves: - initialize basic node types - initialize basic permissions - create and connect storage space """ api = system_util.pillar_api() project_properties = dict( name='My project', user=current_user.objectid, category='assets', status='pending' ) project = Project(project_properties) project.create(api=api) return redirect(url_for('projects.edit', project_url="p-{}".format(project['_id']))) @blueprint.route('/delete', methods=['POST']) @login_required def delete(): """Unapologetically deletes a project""" api = system_util.pillar_api() project_id = request.form['project_id'] project = Project.find(project_id, api=api) project.delete(api=api) return jsonify(dict(staus='success', data=dict( message='Project deleted {}'.format(project['_id']))))