import functools import logging from flask import Blueprint, render_template, redirect, url_for, request, jsonify, session import flask_login import werkzeug.exceptions as wz_exceptions from pillar.auth import current_user as current_user from pillar.api.utils import str2id from pillar.web.utils import mass_attach_project_pictures import pillar.web.subquery from pillar.web.system_util import pillar_api from pillar.web.projects.routes import project_view, project_navigation_links import pillarsdk from attract import current_attract from attract.node_types.task import node_type_task from attract.node_types.shot import node_type_shot from attract.node_types.asset import node_type_asset blueprint = Blueprint('attract', __name__) log = logging.getLogger(__name__) @blueprint.route('/') def index(): api = pillar_api() user = flask_login.current_user if user.is_authenticated: tasks = current_attract.task_manager.tasks_for_user(user.objectid) else: tasks = None # TODO: add projections. projects = current_attract.attract_projects() if current_user.is_anonymous: # Headers are only shown in index_anon_left_column.pug mass_attach_project_pictures(projects['_items'], square=False, api=api) projs_with_summaries = [ (proj, current_attract.shot_manager.shot_status_summary(proj['_id'])) for proj in projects['_items'] ] # Fetch all activities for all Attract projects. id_to_proj = {p['_id']: p for p in projects['_items']} activities = pillarsdk.Activity.all({ 'where': { 'project': {'$in': list(id_to_proj.keys())}, }, 'sort': [('_id', 1)], # Sort by creation, _id is incremental. 'max_results': 20, }, api=api) # Fetch more info for each activity. for act in activities['_items']: act.actor_user = pillar.web.subquery.get_user_info(act.actor_user) act.project = id_to_proj[act.project] try: act.link = current_attract.link_for_activity(act) except (ValueError, wz_exceptions.NotFound): act.link = None project = session.get('attract_last_project') return render_template('attract/index.html', tasks=tasks, projs_with_summaries=projs_with_summaries, activities=activities, project=project) def error_project_not_setup_for_attract(): return render_template('attract/errors/project_not_setup.html') def attract_project_view(extra_project_projections: dict=None, extension_props=False, *, full_project=False): """Decorator, replaces the first parameter project_url with the actual project. Assumes the first parameter to the decorated function is 'project_url'. It then looks up that project, checks that it's set up for Attract, and passes it to the decorated function. If not set up for attract, uses error_project_not_setup_for_attract() to render the response. :param extra_project_projections: extra projections to use on top of the ones already used by this decorator. :param extension_props: whether extension properties should be included. Includes them in the projections, and verifies that they are there. :param full_project: skip projections altogether, fetching the whole project. """ if callable(extra_project_projections): raise TypeError('Use with @attract_project_view() <-- note the parentheses') projections = { '_id': 1, 'name': 1, 'node_types': 1, 'nodes_featured': 1, 'extension_props': 1, # We don't need this here, but this way the wrapped function has access # to the orignal URL passed to it. 'url': 1, } if extra_project_projections: projections.update(extra_project_projections) def decorator(wrapped): @functools.wraps(wrapped) def wrapper(project_url, *args, **kwargs): if isinstance(project_url, pillarsdk.Resource): # This is already a resource, so this call probably is from one # view to another. Assume the caller knows what he's doing and # just pass everything along. return wrapped(project_url, *args, **kwargs) if current_user.is_anonymous: log.debug('attract_project_view: Anonymous user never has access to Attract.') raise wz_exceptions.Forbidden() api = pillar_api() projection_param = None if full_project else {'projection': projections} project = pillarsdk.Project.find_by_url( project_url, projection_param, api=api) is_attract = current_attract.is_attract_project(project, test_extension_props=extension_props) if not is_attract: return error_project_not_setup_for_attract() session['attract_last_project'] = project.to_dict() # Check user access. auth = current_attract.auth auth.determine_user_rights(str2id(project['_id'])) if not auth.current_user_may(auth.Actions.VIEW): log.info('User %s not allowed to use Attract', current_user) raise wz_exceptions.Forbidden() if extension_props: pprops = project.extension_props.attract return wrapped(project, pprops, *args, **kwargs) return wrapped(project, *args, **kwargs) return wrapper return decorator @blueprint.route('/') @attract_project_view(extension_props=False) def project_index(project): return redirect(url_for('attract.shots.perproject.index', project_url=project.url)) @blueprint.route('//help') @attract_project_view(extension_props=False) def help(project): nt_task = project.get_node_type(node_type_task['name']) nt_shot = project.get_node_type(node_type_shot['name']) statuses = set(nt_task['dyn_schema']['status']['allowed'] + nt_shot['dyn_schema']['status']['allowed']) return render_template('attract/help.html', statuses=statuses) def project_settings(project: pillarsdk.Project, **template_args: dict): """Renders the project settings page for Attract projects.""" from . import EXTENSION_NAME if not current_attract.auth.current_user_is_attract_user(): raise wz_exceptions.Forbidden() # Based on the project state, we can render a different template. if not current_attract.is_attract_project(project): return render_template('attract/project_settings/offer_setup.html', project=project, **template_args) ntn_shot = node_type_shot['name'] ntn_asset = node_type_asset['name'] try: attract_props = project['extension_props'][EXTENSION_NAME] except KeyError: # Not set up for attract, can happen. shot_task_types = [] asset_task_types = [] else: shot_task_types = attract_props['task_types'][ntn_shot] asset_task_types = attract_props['task_types'][ntn_asset] return render_template('attract/project_settings/settings.html', project=project, asset_node_type_name=ntn_asset, shot_node_type_name=ntn_shot, shot_task_types=shot_task_types, asset_task_types=asset_task_types, **template_args) @blueprint.route('///set-task-types', methods=['POST']) @attract_project_view(extension_props=True, full_project=True) def save_task_types(project, attract_props, node_type_name: str): from . import EXTENSION_NAME from . import setup import re from collections import OrderedDict valid_name_re = re.compile(r'^[0-9a-zA-Z &+\-.,_]+$') if (not node_type_name.startswith('%s_' % EXTENSION_NAME) or node_type_name not in attract_props['task_types'] or not valid_name_re.match(node_type_name)): log.info('%s: received invalid node type name %r', request.endpoint, node_type_name) raise wz_exceptions.BadRequest('Invalid node type name') task_types_field = request.form.get('task_types') if not task_types_field: raise wz_exceptions.BadRequest('No task types given') task_types = [ tt for tt in (tt.strip() for tt in task_types_field.split('\n')) if tt ] task_types = list(OrderedDict.fromkeys(task_types)) # removes duplicates, maintains order. if not all(valid_name_re.match(tt) for tt in task_types): raise wz_exceptions.BadRequest('Invalid task type given') setup.set_task_types(project.to_dict(), node_type_name, task_types) return jsonify(task_types=task_types) @blueprint.route('//setup-for-attract', methods=['POST']) @flask_login.login_required @project_view() def setup_for_attract(project: pillarsdk.Project): import attract.setup project_id = project._id if not project.has_method('PUT'): log.warning('User %s tries to set up project %s for Attract, but has no PUT rights.', current_user, project_id) raise wz_exceptions.Forbidden() log.info('User %s sets up project %s for Attract', current_user, project_id) attract.setup.setup_for_attract(project.url) return '', 204