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.
262 lines
9.4 KiB
Python
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
|