Files
attract/attract/routes.py
Sybren A. Stüvel 1e1420d92b Fixed race condition in fetching task activities
Since MongoDB stores timestamps with a resolution of a millisecond, it was
possible for a task to be created and updated on the same timestamp, which
could cause an impossible ordering of the activities (edit before creation).
Sorting by ID instead of creation timestamp fixes this.
2019-05-14 14:26:27 +02:00

262 lines
9.4 KiB
Python

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('/<project_url>')
@attract_project_view(extension_props=False)
def project_index(project):
return redirect(url_for('attract.shots.perproject.index', project_url=project.url))
@blueprint.route('/<project_url>/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('/<project_url>/<node_type_name>/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('/<project_url>/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