import os import json import logging from datetime import datetime import pillarsdk from pillarsdk import Node from pillarsdk import Project from pillarsdk.exceptions import ResourceNotFound from pillarsdk.exceptions import ForbiddenAccess from flask import Blueprint, current_app from flask import redirect from flask import render_template from flask import url_for from flask import request from flask import jsonify from flask import abort from flask_login import current_user from werkzeug.exceptions import NotFound from wtforms import SelectMultipleField from flask.ext.login import login_required from jinja2.exceptions import TemplateNotFound from pillar.web.utils import caching from pillar.web.nodes.forms import get_node_form from pillar.web.nodes.forms import process_node_form from pillar.web.nodes.custom.storage import StorageNode from pillar.web.projects.routes import project_update_nodes_list from pillar.web.utils import get_file from pillar.web.utils.jstree import jstree_build_children from pillar.web.utils.jstree import jstree_build_from_node from pillar.web.utils.forms import ProceduralFileSelectForm from pillar.web.utils.forms import build_file_select_form from pillar.web import system_util blueprint = Blueprint('nodes', __name__) log = logging.getLogger(__name__) def get_node(node_id, user_id): api = system_util.pillar_api() node = Node.find(node_id + '/?embedded={"node_type":1}', api=api) return node.to_dict() def get_node_children(node_id, node_type_name, user_id): """This function is currently unused since it does not give significant performance improvements. """ api = system_util.pillar_api() if node_type_name == 'group': published_status = ',"properties.status": "published"' else: published_status = '' children = Node.all({ 'where': '{"parent": "%s" %s}' % (node_id, published_status), 'embedded': '{"node_type": 1}'}, api=api) return children.to_dict() @blueprint.route("//jstree") def jstree(node_id): """JsTree view. This return a lightweight version of the node, to be used by JsTree in the frontend. We have two possible cases: - https://pillar//jstree (construct the whole expanded tree starting from the node_id. Use only once) - https://pillar//jstree&children=1 (deliver the children of a node - use in the navigation of the tree) """ # Get node with basic embedded data api = system_util.pillar_api() node = Node.find(node_id, { 'projection': { 'name': 1, 'node_type': 1, 'parent': 1, 'project': 1, 'properties.content_type': 1, } }, api=api) if request.args.get('children') != '1': return jsonify(items=jstree_build_from_node(node)) if node.node_type == 'storage': storage = StorageNode(node) # Check if we specify a path within the storage path = request.args.get('path') # Generate the storage listing listing = storage.browse(path) # Inject the current node id in the response, so that JsTree can # expose the storage_node property and use it for further queries listing['storage_node'] = node._id if 'children' in listing: for child in listing['children']: child['storage_node'] = node._id return jsonify(listing) return jsonify(jstree_build_children(node)) @blueprint.route("//view") def view(node_id): api = system_util.pillar_api() # Get node, we'll embed linked objects later. try: node = Node.find(node_id, api=api) except ResourceNotFound: return render_template('errors/404_embed.html') except ForbiddenAccess: return render_template('errors/403_embed.html') node_type_name = node.node_type if node_type_name == 'post': # Posts shouldn't be shown at this route, redirect to the correct one. return redirect(url_for_node(node=node)) # Set the default name of the template path based on the node name template_path = os.path.join('nodes', 'custom', node_type_name) # Set the default action for a template. By default is view and we override # it only if we are working storage nodes, where an 'index' is also possible template_action = 'view' def allow_link(): """Helper function to cross check if the user is authenticated, and it is has the 'subscriber' role. Also, we check if the node has world GET permissions, which means it's free. """ # Check if node permissions for the world exist (if node is free) if node.permissions and node.permissions.world: return 'GET' in node.permissions.world if current_user.is_authenticated: allowed_roles = {u'subscriber', u'demo', u'admin'} return bool(allowed_roles.intersection(current_user.roles or ())) return False link_allowed = allow_link() node_type_handlers = { 'asset': _view_handler_asset, 'storage': _view_handler_storage, 'texture': _view_handler_texture, 'hdri': _view_handler_hdri, } if node_type_name in node_type_handlers: handler = node_type_handlers[node_type_name] template_path, template_action = handler(node, template_path, template_action, link_allowed) # Fetch linked resources. node.picture = get_file(node.picture, api=api) node.user = node.user and pillarsdk.User.find(node.user, api=api) try: node.parent = node.parent and pillarsdk.Node.find(node.parent, api=api) except ForbiddenAccess: # This can happen when a node has world-GET, but the parent doesn't. node.parent = None # Get children children_projection = {'project': 1, 'name': 1, 'picture': 1, 'parent': 1, 'node_type': 1, 'properties.order': 1, 'properties.status': 1, 'user': 1, 'properties.content_type': 1} children_where = {'parent': node._id} if node_type_name == 'group': children_where['properties.status'] = 'published' children_projection['permissions.world'] = 1 else: children_projection['properties.files'] = 1 children_projection['properties.is_tileable'] = 1 try: children = Node.all({ 'projection': children_projection, 'where': children_where, 'sort': [('properties.order', 1), ('name', 1)]}, api=api) except ForbiddenAccess: return render_template('errors/403_embed.html') children = children._items for child in children: child.picture = get_file(child.picture, api=api) if request.args.get('format') == 'json': node = node.to_dict() node['url_edit'] = url_for('nodes.edit', node_id=node['_id']) return jsonify({ 'node': node, 'children': children.to_dict() if children else {}, 'parent': node['parent'] if 'parent' in node else {} }) if 't' in request.args: template_path = os.path.join('nodes', 'custom', 'asset') template_action = 'view_theatre' template_path = '{0}/{1}_embed.html'.format(template_path, template_action) try: return render_template(template_path, node_id=node._id, node=node, parent=node.parent, children=children, config=current_app.config, api=api) except TemplateNotFound: log.error('Template %s does not exist for node type %s', template_path, node_type_name) return render_template('nodes/error_type_not_found.html', node_id=node._id, node=node, parent=node.parent, children=children, config=current_app.config, api=api) def _view_handler_asset(node, template_path, template_action, link_allowed): # Attach the file document to the asset node node_file = get_file(node.properties.file) node.file = node_file # Remove the link to the file if it's not allowed. if node_file and not link_allowed: node.file.link = None if node_file and node_file.content_type is not None: asset_type = node_file.content_type.split('/')[0] else: asset_type = None if asset_type == 'video': # Process video type and select video template if link_allowed: sources = [] if node_file and node_file.variations: for f in node_file.variations: sources.append({'type': f.content_type, 'src': f.link}) # Build a link that triggers download with proper filename # TODO: move this to Pillar if f.backend == 'cdnsun': f.link = "{0}&name={1}.{2}".format(f.link, node.name, f.format) node.video_sources = json.dumps(sources) node.file_variations = node_file.variations else: node.video_sources = None node.file_variations = None elif asset_type != 'image': # Treat it as normal file (zip, blend, application, etc) asset_type = 'file' template_path = os.path.join(template_path, asset_type) return template_path, template_action def _view_handler_storage(node, template_path, template_action, link_allowed): storage = StorageNode(node) path = request.args.get('path') listing = storage.browse(path) node.name = listing['name'] listing['storage_node'] = node._id # If the item has children we are working with a group if 'children' in listing: for child in listing['children']: child['storage_node'] = node._id child['name'] = child['text'] child['content_type'] = os.path.dirname(child['type']) node.children = listing['children'] template_action = 'index' else: node.status = 'published' node.length = listing['size'] node.download_link = listing['signed_url'] return template_path, template_action def _view_handler_texture(node, template_path, template_action, link_allowed): for f in node.properties.files: f.file = get_file(f.file) # Remove the link to the file if it's not allowed. if f.file and not link_allowed: f.file.link = None return template_path, template_action def _view_handler_hdri(node, template_path, template_action, link_allowed): if not link_allowed: node.properties.files = None else: for f in node.properties.files: f.file = get_file(f.file) return template_path, template_action @blueprint.route("//edit", methods=['GET', 'POST']) @login_required def edit(node_id): """Generic node editing form """ def set_properties(dyn_schema, form_schema, node_properties, form, prefix="", set_data=True): """Initialize custom properties for the form. We run this function once before validating the function with set_data=False, so that we can set any multiselect field that was originally specified empty and fill it with the current choices. """ for prop in dyn_schema: schema_prop = dyn_schema[prop] form_prop = form_schema.get(prop, {}) prop_name = "{0}{1}".format(prefix, prop) if schema_prop['type'] == 'dict': set_properties( schema_prop['schema'], form_prop['schema'], node_properties[prop_name], form, "{0}__".format(prop_name)) continue if prop_name not in form: continue try: db_prop_value = node_properties[prop] except KeyError: log.debug('%s not found in form for node %s', prop_name, node_id) continue if schema_prop['type'] == 'datetime': db_prop_value = datetime.strptime(db_prop_value, current_app.config['RFC1123_DATE_FORMAT']) if isinstance(form[prop_name], SelectMultipleField): # If we are dealing with a multiselect field, check if # it's empty (usually because we can't query the whole # database to pick all the choices). If it's empty we # populate the choices with the actual data. if not form[prop_name].choices: form[prop_name].choices = [(d, d) for d in db_prop_value] # Choices should be a tuple with value and name # Assign data to the field if set_data: if prop_name == 'attachments': for attachment_collection in db_prop_value: for a in attachment_collection['files']: attachment_form = ProceduralFileSelectForm() attachment_form.file = a['file'] attachment_form.slug = a['slug'] attachment_form.size = 'm' form[prop_name].append_entry(attachment_form) elif prop_name == 'files': schema = schema_prop['schema']['schema'] # Extra entries are caused by min_entries=1 in the form # creation. field_list = form[prop_name] if len(db_prop_value) > 0: while len(field_list): field_list.pop_entry() for file_data in db_prop_value: file_form_class = build_file_select_form(schema) subform = file_form_class() for key, value in file_data.iteritems(): setattr(subform, key, value) field_list.append_entry(subform) # elif prop_name == 'tags': # form[prop_name].data = ', '.join(data) else: form[prop_name].data = db_prop_value else: # Default population of multiple file form list (only if # we are getting the form) if request.method == 'POST': continue if prop_name == 'attachments': if not db_prop_value: attachment_form = ProceduralFileSelectForm() attachment_form.file = 'file' attachment_form.slug = '' attachment_form.size = '' form[prop_name].append_entry(attachment_form) api = system_util.pillar_api() node = Node.find(node_id, api=api) project = Project.find(node.project, api=api) node_type = project.get_node_type(node.node_type) form = get_node_form(node_type) user_id = current_user.objectid dyn_schema = node_type['dyn_schema'].to_dict() form_schema = node_type['form_schema'].to_dict() error = "" node_properties = node.properties.to_dict() ensure_lists_exist_as_empty(node.to_dict(), node_type) set_properties(dyn_schema, form_schema, node_properties, form, set_data=False) if form.validate_on_submit(): if process_node_form(form, node_id=node_id, node_type=node_type, user=user_id): # Handle the specific case of a blog post if node_type.name == 'post': project_update_nodes_list(node, list_name='blog') else: project_update_nodes_list(node) # Emergency hardcore cache flush # cache.clear() return redirect(url_for('nodes.view', node_id=node_id, embed=1, _external=True, _scheme=current_app.config['SCHEME'])) else: log.debug('Error sending data to Pillar, see Pillar logs.') error = 'Server error' else: if form.errors: log.debug('Form errors: %s', form.errors) # Populate Form form.name.data = node.name form.description.data = node.description if 'picture' in form: form.picture.data = node.picture if node.parent: form.parent.data = node.parent set_properties(dyn_schema, form_schema, node_properties, form) # Get previews node.picture = get_file(node.picture, api=api) if node.picture else None # Get Parent try: parent = Node.find(node['parent'], api=api) except KeyError: parent = None except ResourceNotFound: parent = None embed_string = '' # Check if we want to embed the content via an AJAX call if request.args.get('embed'): if request.args.get('embed') == '1': # Define the prefix for the embedded template embed_string = '_embed' template = '{0}/edit{1}.html'.format(node_type['name'], embed_string) # We should more simply check if the template file actually exsists on # the filesystem level try: return render_template( template, node=node, parent=parent, form=form, errors=form.errors, error=error, api=api) except TemplateNotFound: template = 'nodes/edit{1}.html'.format(node_type['name'], embed_string) return render_template( template, node=node, parent=parent, form=form, errors=form.errors, error=error, api=api) def ensure_lists_exist_as_empty(node_doc, node_type): """Ensures that any properties of type 'list' exist as empty lists. This allows us to iterate over lists without worrying that they are set to None. Only works for top-level list properties. """ node_properties = node_doc.setdefault('properties', {}) for prop, schema in node_type.dyn_schema.to_dict().iteritems(): if schema['type'] != 'list': continue if node_properties.get(prop) is None: node_properties[prop] = [] @blueprint.route('/create', methods=['POST']) @login_required def create(): """Create a node. Requires a number of params: - project id - node_type - parent node (optional) """ if request.method != 'POST': return abort(403) project_id = request.form['project_id'] parent_id = request.form.get('parent_id') node_type_name = request.form['node_type_name'] api = system_util.pillar_api() # Fetch the Project or 404 try: project = Project.find(project_id, api=api) except ResourceNotFound: return abort(404) node_type = project.get_node_type(node_type_name) node_type_name = 'folder' if node_type['name'] == 'group' else \ node_type['name'] node_props = dict( name='New {}'.format(node_type_name), project=project['_id'], user=current_user.objectid, node_type=node_type['name'], properties={} ) if parent_id: node_props['parent'] = parent_id ensure_lists_exist_as_empty(node_props, node_type) node = Node(node_props) node.create(api=api) return jsonify(status='success', data=dict(asset_id=node['_id'])) @blueprint.route("//redir") def redirect_to_context(node_id): """Redirects to the context URL of the node. Comment: redirects to whatever the comment is attached to + #node_id (unless 'whatever the comment is attached to' already contains '#', then '#node_id' isn't appended) Post: redirects to main or project-specific blog post Other: redirects to project.url + #node_id """ if node_id.lower() == '{{objectid}}': log.warning("JavaScript should have filled in the ObjectID placeholder, but didn't. " "URL=%s and referrer=%s", request.url, request.referrer) raise NotFound('Invalid ObjectID') try: url = url_for_node(node_id) except ValueError as ex: log.warning("%s: URL=%s and referrer=%s", str(ex), request.url, request.referrer) raise NotFound('Invalid ObjectID') return redirect(url) def url_for_node(node_id=None, node=None): assert isinstance(node_id, (basestring, type(None))) api = system_util.pillar_api() # Find node by its ID, or the ID by the node, depending on what was passed # as parameters. if node is None: try: node = Node.find(node_id, api=api) except ResourceNotFound: log.warning( 'url_for_node(node_id=%r, node=None): Unable to find node.', node_id) raise ValueError('Unable to find node %r' % node_id) elif node_id is None: node_id = node['_id'] else: raise ValueError('Either node or node_id must be given') return _find_url_for_node(node_id, node=node) @caching.cache_for_request() def project_url(project_id, project): """Returns the project, raising a ValueError if it can't be found. Uses the "urler" service endpoint. """ if project is not None: return project urler_api = system_util.pillar_api( token=current_app.config['URLER_SERVICE_AUTH_TOKEN']) return Project.find_from_endpoint( '/service/urler/%s' % project_id, api=urler_api) # Cache the actual URL based on the node ID, for the duration of the request. @caching.cache_for_request() def _find_url_for_node(node_id, node): api = system_util.pillar_api() # Find the node's project, or its ID, depending on whether a project # was embedded. This is needed in two of the three finder functions. project_id = node.project if isinstance(project_id, pillarsdk.Resource): # Embedded project project = project_id project_id = project['_id'] else: project = None def find_for_comment(): """Returns the URL for a comment.""" parent = node while parent.node_type == 'comment': if isinstance(parent.parent, pillarsdk.Resource): parent = parent.parent continue try: parent = Node.find(parent.parent, api=api) except ResourceNotFound: log.warning( 'url_for_node(node_id=%r): Unable to find parent node %r', node_id, parent.parent) raise ValueError('Unable to find parent node %r' % parent.parent) # Find the redirection URL for the parent node. parent_url = url_for_node(node=parent) if '#' in parent_url: # We can't attach yet another fragment, so just don't link to # the comment for now. return parent_url return parent_url + '#{}'.format(node_id) def find_for_post(): """Returns the URL for a blog post.""" if str(project_id) == current_app.config['MAIN_PROJECT_ID']: return url_for('main.main_blog', url=node.properties.url) the_project = project_url(project_id, project=project) return url_for('main.project_blog', project_url=the_project.url, url=node.properties.url) # Fallback: Assets, textures, and other node types. def find_for_other(): the_project = project_url(project_id, project=project) return url_for('projects.view_node', project_url=the_project.url, node_id=node_id) # Determine which function to use to find the correct URL. url_finders = { 'comment': find_for_comment, 'post': find_for_post, } finder = url_finders.get(node.node_type, find_for_other) return finder() # Import of custom modules (using the same nodes decorator) import custom.comments import custom.groups import custom.storage import custom.posts