For the user: Ctrl + L-Mouse to select multiple tasks/shots/assets and then edit the nodes as before. When multiple items are selected a chain icon can be seen in editor next to the fields. If the chain is broken it indicates that the values are not the same on all the selected items. When a field has been edited it will be marked with a green background color. The items are saved one by one in parallel. This means that one item could fail to be saved, while the others get updated. For developers: The editor and activities has been ported to Vue. The table and has been updated to support multi select. MultiEditEngine is the core of the multi edit. It keeps track of what values differs and what has been edited.
200 lines
7.6 KiB
Python
200 lines
7.6 KiB
Python
import logging
|
|
from dateutil import parser
|
|
|
|
from flask import Blueprint, render_template, request, current_app, session
|
|
import flask
|
|
import flask_login
|
|
import werkzeug.exceptions as wz_exceptions
|
|
|
|
import pillarsdk
|
|
from pillar.web.projects.routes import project_navigation_links
|
|
from pillar.web.system_util import pillar_api
|
|
import pillar.api.utils
|
|
import pillar.web.subquery
|
|
from pillar.auth import current_user
|
|
|
|
from attract.routes import attract_project_view
|
|
from attract.node_types.task import node_type_task
|
|
from attract.node_types.shot import node_type_shot
|
|
from attract import current_attract, EXTENSION_NAME
|
|
|
|
blueprint = Blueprint('attract.tasks', __name__, url_prefix='/tasks')
|
|
perproject_blueprint = Blueprint('attract.tasks.perproject', __name__,
|
|
url_prefix='/<project_url>/tasks')
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
@blueprint.route('/')
|
|
def index():
|
|
user = flask_login.current_user
|
|
if not user.is_authenticated:
|
|
return render_template('attract/tasks/index.html')
|
|
|
|
project = session.get('attract_last_project')
|
|
tasks = current_attract.task_manager.tasks_for_user(user.objectid)
|
|
return render_template('attract/tasks/for_user.html',
|
|
tasks=tasks['_items'],
|
|
project=project,
|
|
task_count=tasks['_meta']['total'])
|
|
|
|
|
|
@blueprint.route('/<task_id>', methods=['DELETE'])
|
|
@flask_login.login_required
|
|
def delete(task_id):
|
|
log.info('Deleting task %s', task_id)
|
|
|
|
etag = request.form['etag']
|
|
current_attract.task_manager.delete_task(task_id, etag)
|
|
|
|
return '', 204
|
|
|
|
|
|
@perproject_blueprint.route('/', endpoint='index')
|
|
@attract_project_view(extension_props=False)
|
|
def for_project(project, task_id=None):
|
|
tasks = current_attract.task_manager.tasks_for_project(project['_id'])
|
|
can_use_attract = current_attract.auth.current_user_may(current_attract.auth.Actions.USE)
|
|
navigation_links = project_navigation_links(project, pillar_api())
|
|
extension_sidebar_links = current_app.extension_sidebar_links(project)
|
|
|
|
return render_template('attract/tasks/for_project.html',
|
|
tasks=tasks['_items'],
|
|
selected_id=task_id,
|
|
project=project,
|
|
can_use_attract=can_use_attract,
|
|
can_create_task=can_use_attract,
|
|
navigation_links=navigation_links,
|
|
extension_sidebar_links=extension_sidebar_links)
|
|
|
|
|
|
@perproject_blueprint.route('/<task_id>')
|
|
@attract_project_view(extension_props=True)
|
|
def view_task(project, attract_props, task_id):
|
|
if not request.is_xhr:
|
|
return for_project(project, task_id=task_id)
|
|
|
|
# Task list is public, task details are not.
|
|
if not current_user.has_cap('attract-view'):
|
|
raise wz_exceptions.Forbidden()
|
|
|
|
api = pillar_api()
|
|
task = pillarsdk.Node.find(task_id, api=api)
|
|
node_type = project.get_node_type(node_type_task['name'])
|
|
|
|
# Figure out which task types are available, defaulting to the shot task types.
|
|
context = request.args.get('context', None) or 'shot'
|
|
task_types = task_types_given_context(project, attract_props, context, task)
|
|
|
|
if task.properties.due_date:
|
|
task.properties.due_date = parser.parse('%s' % task.properties.due_date)
|
|
|
|
# Fetch project users so that we can assign them tasks
|
|
auth = current_attract.auth
|
|
can_use_attract = auth.current_user_may(auth.Actions.USE)
|
|
can_edit = 'PUT' in task.allowed_methods and can_use_attract
|
|
|
|
if can_edit:
|
|
users = project.get_users(api=api)
|
|
project.users = users['_items']
|
|
else:
|
|
try:
|
|
user_ids = task.properties.assigned_to.users
|
|
except AttributeError:
|
|
task.properties['assigned_to'] = {'users': []}
|
|
else:
|
|
task.properties.assigned_to.users = [pillar.web.subquery.get_user_info(uid)
|
|
for uid in user_ids]
|
|
|
|
return render_template('attract/tasks/view_task_embed.html',
|
|
task=task,
|
|
project=project,
|
|
task_node_type=node_type,
|
|
task_types=task_types,
|
|
attract_props=attract_props.to_dict(),
|
|
attract_context=request.args.get('context'),
|
|
can_use_attract=can_use_attract,
|
|
can_edit=can_edit)
|
|
|
|
|
|
def task_types_given_context(project, attract_props, page_context, task):
|
|
"""Returns a list of task types, given the page context and/or task parent type."""
|
|
|
|
# If we're in an explicit shot/asset context, just use that.
|
|
if page_context in {'shot', 'asset'}:
|
|
ctx_node_type_name = '%s_%s' % (EXTENSION_NAME, page_context)
|
|
try:
|
|
return attract_props['task_types'][ctx_node_type_name]
|
|
except KeyError:
|
|
log.warning('Project %s does not have an Attract task type definition for %s',
|
|
project['_id'], ctx_node_type_name)
|
|
# Fall through to the case below.
|
|
|
|
# If we're not in such a context, we need to inspect the parent node type (if any).
|
|
if task.parent:
|
|
api = pillar_api()
|
|
parent = pillarsdk.Node.find(task.parent, {'projection': {'node_type': 1}}, api=api)
|
|
if parent:
|
|
try:
|
|
return attract_props['task_types'][parent['node_type']]
|
|
except KeyError:
|
|
log.warning('Project %s does not have an Attract task type definition for %s',
|
|
project['_id'], parent['node_type'])
|
|
# Fall through to the fallback case below.
|
|
|
|
# Just fall back to shot task types
|
|
try:
|
|
return attract_props['task_types'][node_type_shot['name']]
|
|
except KeyError:
|
|
log.warning('Project %s does not have an Attract task type definition for %s',
|
|
project['_id'], parent['node_type'])
|
|
# Fall through to the fallback case below.
|
|
|
|
# Fallback in case of total failure.
|
|
return []
|
|
|
|
|
|
@perproject_blueprint.route('/<task_id>', methods=['POST'])
|
|
@attract_project_view()
|
|
def save(project, task_id):
|
|
log.info('Saving task %s', task_id)
|
|
log.debug('Form data: %s', request.form)
|
|
|
|
task_dict = request.form.to_dict()
|
|
task_dict['users'] = request.form.getlist('users')
|
|
|
|
task = current_attract.task_manager.edit_task(task_id, **task_dict)
|
|
|
|
return pillar.api.utils.jsonify(task.to_dict())
|
|
|
|
|
|
@perproject_blueprint.route('/create', methods=['POST'])
|
|
@attract_project_view()
|
|
def create_task(project):
|
|
task_type = request.form['task_type']
|
|
parent = request.form.get('parent', None)
|
|
|
|
task = current_attract.task_manager.create_task(project,
|
|
task_type=task_type,
|
|
parent=parent)
|
|
|
|
resp = flask.make_response()
|
|
resp.headers['Location'] = flask.url_for('.view_task',
|
|
project_url=project['url'],
|
|
task_id=task['_id'])
|
|
resp.status_code = 201
|
|
|
|
return flask.make_response(flask.jsonify(task.to_dict()), 201)
|
|
|
|
|
|
@perproject_blueprint.route('/<task_id>/activities')
|
|
@attract_project_view()
|
|
def activities(project, task_id):
|
|
if not request.is_xhr:
|
|
return flask.redirect(flask.url_for('.view_task',
|
|
project_url=project.url,
|
|
task_id=task_id))
|
|
|
|
acts = current_attract.activities_for_node(task_id)
|
|
return flask.render_template('attract/tasks/view_activities_embed.html',
|
|
activities=acts)
|