diff --git a/attract/__init__.py b/attract/__init__.py index 54818a3..c408251 100644 --- a/attract/__init__.py +++ b/attract/__init__.py @@ -151,10 +151,13 @@ class AttractExtension(PillarExtension): import pprint self._log.debug('Project: %s', pprint.pformat(project.to_dict())) return False + except KeyError: + # Not set up for Attract + return False if pprops is None: - self._log.warning("is_attract_project: Project url=%r doesn't have Attract" - " extension properties.", project['url']) + self._log.debug("is_attract_project: Project url=%r doesn't have Attract" + " extension properties.", project['url']) return False return True @@ -166,6 +169,20 @@ class AttractExtension(PillarExtension): return flask.render_template('attract/sidebar.html', project=project) + def project_settings(self, project: pillarsdk.Project, **template_args: dict) -> flask.Response: + """Renders the project settings page for this extension. + + Set YourExtension.has_project_settings = True and Pillar will call this function. + + :param project: the project for which to render the settings. + :param template_args: additional template arguments. + :returns: a Flask HTTP response + """ + + from attract.routes import project_settings + + return project_settings(project, **template_args) + def activities_for_node(self, node_id, max_results=20, page=1): """Returns a page of activities for the given task or shot. @@ -221,11 +238,11 @@ class AttractExtension(PillarExtension): return url_for_node(node_id=act.object) -def _get_current_attract(): +def _get_current_attract() -> AttractExtension: """Returns the Attract extension of the current application.""" return flask.current_app.pillar_extensions[EXTENSION_NAME] -current_attract = LocalProxy(_get_current_attract) +current_attract: AttractExtension = LocalProxy(_get_current_attract) """Attract extension of the current app.""" diff --git a/attract/routes.py b/attract/routes.py index 0cafded..689102d 100644 --- a/attract/routes.py +++ b/attract/routes.py @@ -1,18 +1,21 @@ import functools import logging -from flask import Blueprint, render_template, redirect, url_for +from flask import Blueprint, render_template, redirect, url_for, request, jsonify import flask_login import werkzeug.exceptions as wz_exceptions +from pillar.auth import current_web_user as current_user from pillar.web.utils import attach_project_pictures import pillar.web.subquery from pillar.web.system_util import pillar_api +from pillar.web.projects.routes import project_view 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__) @@ -38,7 +41,7 @@ def index(): 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']} @@ -137,8 +140,8 @@ def attract_project_view(extra_project_projections=None, extension_props=False): @blueprint.route('/') -@attract_project_view(extension_props=True) -def project_index(project, attract_props): +@attract_project_view(extension_props=False) +def project_index(project): return redirect(url_for('attract.shots.perproject.index', project_url=project.url)) @@ -152,3 +155,88 @@ def help(project): 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 + + # 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) +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 diff --git a/attract/setup.py b/attract/setup.py index f8bae7a..34c5790 100644 --- a/attract/setup.py +++ b/attract/setup.py @@ -1,11 +1,7 @@ -"""Setting up projects for Attract. +"""Setting up projects for Attract.""" -This is intended to be used by the CLI and unittests only, not tested -for live/production situations. -""" - -import copy import logging +import typing from bson import ObjectId from eve.methods.put import put_internal @@ -108,3 +104,13 @@ def setup_for_attract(project_url, replace=False, svn_url=None): log.info('Project %s was updated for Attract.', project_url) return project + + +def set_task_types(project: dict, node_type_name: str, task_types: typing.List[str]): + eprops = project['extension_props'] + attract_props = eprops[EXTENSION_NAME] + attract_props['task_types'][node_type_name] = task_types + + log.info('Updating project %s, setting %s task_types to [%s]', + project['url'], node_type_name, ', '.join(task_types)) + _update_project(project) diff --git a/src/scripts/tutti/00_utils.js b/src/scripts/tutti/00_utils.js index e2b5552..a620b1f 100644 --- a/src/scripts/tutti/00_utils.js +++ b/src/scripts/tutti/00_utils.js @@ -178,3 +178,23 @@ function setHeaderCellsWidth(tableHeaderRowOriginal, tableHeaderRowFixed) { return table_header.eq(i).width(); }); } + +/* Returns a more-or-less reasonable message given an error response object. */ +function xhrErrorResponseMessage(err) { + if (typeof err.responseJSON == 'undefined') + return err.statusText; + + if (typeof err.responseJSON._error != 'undefined' && typeof err.responseJSON._error.message != 'undefined') + return err.responseJSON._error.message; + + if (typeof err.responseJSON._message != 'undefined') + return err.responseJSON._message + + return err.statusText; +} + +function xhrErrorResponseElement(err, prefix) { + msg = xhrErrorResponseMessage(err); + return $('') + .text(prefix + msg); +} diff --git a/src/templates/attract/project_settings/attract_layout.jade b/src/templates/attract/project_settings/attract_layout.jade new file mode 100644 index 0000000..abd9f69 --- /dev/null +++ b/src/templates/attract/project_settings/attract_layout.jade @@ -0,0 +1,23 @@ +| {% extends 'projects/edit_layout.html' %} +| {% set title = 'attract' %} +| {% block page_title %}Attract settings for {{ project.name }}{% endblock %} + +| {% block head %} +script(src="{{ url_for('static_attract', filename='assets/js/generated/tutti.min.js') }}") +| {% endblock %} + +| {% block project_context_header %} +span#project-edit-title + | {{ self.page_title() }} +| {% endblock %} + +| {% block project_context %} +#node-edit-container + | {% block attract_container %} + | {% endblock attract_container %} + + .settings-footer + p + | New to Attract? + | Sorry, we don't have documentation yet. +| {% endblock project_context %} diff --git a/src/templates/attract/project_settings/offer_setup.jade b/src/templates/attract/project_settings/offer_setup.jade new file mode 100644 index 0000000..378859e --- /dev/null +++ b/src/templates/attract/project_settings/offer_setup.jade @@ -0,0 +1,26 @@ +| {% extends 'attract/project_settings/attract_layout.html' %} + +| {% block attract_container %} +#node-edit-form + p This project is not setup for Attract (yet!) + p + button.btn.btn-success(onclick='setupForAttract()') Setup Project for Attract + +| {% endblock attract_container %} + +| {% block footer_scripts %} +script. + function setupForAttract() { + $.ajax({ + url: '{{ url_for( "attract.setup_for_attract", project_url=project.url) }}', + method: 'POST', + }) + .done(function() { + window.location.reload(); + }) + .fail(function(err) { + var err_elt = xhrErrorResponseElement(err, 'Error setting up your project: '); + toastr.error(err_elt); + }); + } +| {% endblock %} diff --git a/src/templates/attract/project_settings/settings.jade b/src/templates/attract/project_settings/settings.jade new file mode 100644 index 0000000..f66a1d9 --- /dev/null +++ b/src/templates/attract/project_settings/settings.jade @@ -0,0 +1,58 @@ +| {% extends 'attract/project_settings/attract_layout.html' %} + +| {% block attract_container %} +#node-edit-form + + h3 Shot-related task types + p Here you can edit the task columns shown in this project's + =' ' + a(href="{{ url_for('attract.shots.perproject.index', project_url=project['url']) }}") shot list + ='.' + + form(onsubmit="save(this, '{{ url_for('attract.save_task_types', project_url=project['url'], node_type_name=shot_node_type_name) }}'); return false;") + .input-group + textarea( + name="task_types", + type="text", + rows="{{ shot_task_types|count + 1 }}", + placeholder='Task types, one per line') {{ '\n'.join(shot_task_types) }} + .input-group + button.btn.btn-success.btn-block(type='submit') + i.pi-check + | Save Shot task types + + h3 Asset-related task types + p Here you can edit the task columns shown in this project's + =' ' + a(href="{{ url_for('attract.assets.perproject.index', project_url=project['url']) }}") asset list + ='.' + + form(onsubmit="save(this, '{{ url_for('attract.save_task_types', project_url=project['url'], node_type_name=asset_node_type_name) }}'); return false;") + .input-group + textarea( + name="task_types", + type="text", + rows="{{ asset_task_types|count + 1 }}", + placeholder='Task types, one per line') {{ '\n'.join(asset_task_types) }} + .input-group + button.btn.btn-success.btn-block(type='submit') + i.pi-check + | Save Asset task types + +| {% endblock %} +| {% block footer_scripts %} +script. + function save(form, url) { + var $input = $(form).find('*[name="task_types"]'); + var task_types = $input.val(); + $.post(url, {'task_types': task_types}) + .done(function(xhr) { + $input.val(xhr.task_types.join('\n')); + $input.attr('rows', xhr.task_types.length + 1); + toastr.success('Task types saved'); + }) + .fail(function(err) { + toastr.error(xhrErrorResponseElement(err, 'Error saving task types: ')); + }); + } +| {% endblock %}