Templates & Flask end-points for managing assets.

This commit is contained in:
2016-11-09 14:57:46 +01:00
parent 3346bb1364
commit 0e170464e6
8 changed files with 507 additions and 65 deletions

View File

@@ -64,14 +64,16 @@ class AttractExtension(PillarExtension):
from . import routes
import attract.tasks.routes
import attract.shots_and_assets.routes
import attract.shots_and_assets.routes_assets
import attract.shots_and_assets.routes_shots
import attract.subversion.routes
return [
routes.blueprint,
attract.tasks.routes.blueprint,
attract.tasks.routes.perproject_blueprint,
attract.shots_and_assets.routes.perproject_blueprint,
attract.shots_and_assets.routes_assets.perproject_blueprint,
attract.shots_and_assets.routes_shots.perproject_blueprint,
attract.subversion.routes.blueprint,
attract.subversion.routes.api_blueprint,
]

View File

@@ -0,0 +1,97 @@
import logging
import flask_login
from flask import Blueprint, render_template, request
import flask
import werkzeug.exceptions as wz_exceptions
import pillarsdk
import pillar.api.utils
from pillar.web.system_util import pillar_api
from attract.routes import attract_project_view
from attract.node_types.asset import node_type_asset, task_types
from attract import current_attract, ROLES_REQUIRED_TO_VIEW_ITEMS
from pillar.web.utils import get_file
from . import routes_common
perproject_blueprint = Blueprint('attract.assets.perproject', __name__,
url_prefix='/<project_url>/assets')
log = logging.getLogger(__name__)
@perproject_blueprint.route('/', endpoint='index')
@perproject_blueprint.route('/with-task/<task_id>', endpoint='with_task')
@attract_project_view(extension_props=True)
def for_project(project, attract_props, task_id=None, asset_id=None):
assets, tasks_for_assets, task_types_for_template = routes_common.for_project(
node_type_asset['name'],
task_types,
project, attract_props, task_id, asset_id)
return render_template('attract/assets/for_project.html',
assets=assets,
tasks_for_assets=tasks_for_assets,
task_types=task_types_for_template,
open_task_id=task_id,
open_asset_id=asset_id,
project=project,
attract_props=attract_props)
@perproject_blueprint.route('/<asset_id>')
@attract_project_view(extension_props=True)
def view_asset(project, attract_props, asset_id):
if not request.is_xhr:
return for_project(project, attract_props, asset_id=asset_id)
asset, node_type = routes_common.view_node(project, asset_id, node_type_asset['name'])
return render_template('attract/assets/view_asset_embed.html',
asset=asset,
project=project,
asset_node_type=node_type,
attract_props=attract_props)
@perproject_blueprint.route('/<asset_id>', methods=['POST'])
@attract_project_view()
def save(project, asset_id):
log.info('Saving asset %s', asset_id)
log.debug('Form data: %s', request.form)
asset_dict = request.form.to_dict()
current_attract.shot_manager.edit_asset(asset_id, **asset_dict)
# Return the patched node in all its glory.
api = pillar_api()
asset = pillarsdk.Node.find(asset_id, api=api)
return pillar.api.utils.jsonify(asset.to_dict())
@perproject_blueprint.route('/create', methods=['POST'])
@attract_project_view()
def create_asset(project):
asset = current_attract.shot_manager.create_asset(project)
resp = flask.make_response()
resp.headers['Location'] = flask.url_for('.view_asset',
project_url=project['url'],
asset_id=asset['_id'])
resp.status_code = 201
return flask.make_response(flask.jsonify({'asset_id': asset['_id']}), 201)
@perproject_blueprint.route('/<asset_id>/activities')
@attract_project_view()
def activities(project, asset_id):
if not request.is_xhr:
return flask.redirect(flask.url_for('.view_asset',
project_url=project.url,
asset_id=asset_id))
acts = current_attract.activities_for_node(asset_id)
# NOTE: this uses the 'shots' template, because it has everything we ever wanted.
return flask.render_template('attract/shots/view_activities_embed.html',
activities=acts)

View File

@@ -0,0 +1,69 @@
import logging
import flask
import flask_login
import werkzeug.exceptions as wz_exceptions
import pillarsdk
from pillar.web.system_util import pillar_api
from pillar.web.utils import get_file
from attract import current_attract, ROLES_REQUIRED_TO_VIEW_ITEMS
log = logging.getLogger(__name__)
def for_project(node_type_name, task_types_for_nt, project, attract_props,
task_id=None, shot_or_asset_id=None):
"""Common view code for assets and shots /attract/<project_url>/{assets,shots}"""
api = pillar_api()
found = pillarsdk.Node.all({
'where': {
'project': project['_id'],
'node_type': node_type_name,
},
'sort': [
('properties.cut_in_timeline_in_frames', 1),
]
}, api=api)
nodes = found['_items']
thumb_placeholder = flask.url_for('static_attract', filename='assets/img/placeholder.jpg')
for node in nodes:
picture = get_file(node.picture, api=api)
if picture:
node._thumbnail = next((var.link for var in picture.variations
if var.size == 't'), thumb_placeholder)
else:
node._thumbnail = thumb_placeholder
# The placeholder can be shown quite small, but otherwise the aspect ratio of
# the actual thumbnail should be taken into account. Since it's different for
# each project, we can't hard-code a proper height.
node._thumbnail_height = '30px' if node._thumbnail is thumb_placeholder else 'auto'
tasks_for_nodes = current_attract.shot_manager.tasks_for_nodes(nodes, task_types_for_nt)
# Append the task type onto which 'other' tasks are mapped.
task_types_for_template = task_types_for_nt + [None]
return nodes, tasks_for_nodes, task_types_for_template
def view_node(project, node_id, node_type_name):
"""Returns the node if the user has access.
Uses attract.ROLES_REQUIRED_TO_VIEW_ITEMS to check permissions.
"""
# asset list is public, asset details are not.
if not flask_login.current_user.has_role(*ROLES_REQUIRED_TO_VIEW_ITEMS):
raise wz_exceptions.Forbidden()
api = pillar_api()
node = pillarsdk.Node.find(node_id, api=api)
node_type = project.get_node_type(node_type_name)
return node, node_type

View File

@@ -10,10 +10,12 @@ import pillar.api.utils
from pillar.web.system_util import pillar_api
from attract.routes import attract_project_view
from attract.node_types.shot import node_type_shot
from attract.node_types.shot import node_type_shot, task_types
from attract import current_attract, ROLES_REQUIRED_TO_VIEW_ITEMS
from pillar.web.utils import get_file
from . import routes_common
perproject_blueprint = Blueprint('attract.shots.perproject', __name__,
url_prefix='/<project_url>/shots')
log = logging.getLogger(__name__)
@@ -23,40 +25,10 @@ log = logging.getLogger(__name__)
@perproject_blueprint.route('/with-task/<task_id>', endpoint='with_task')
@attract_project_view(extension_props=True)
def for_project(project, attract_props, task_id=None, shot_id=None):
api = pillar_api()
found = pillarsdk.Node.all({
'where': {
'project': project['_id'],
'node_type': node_type_shot['name'],
},
'sort': [
('properties.cut_in_timeline_in_frames', 1),
]
}, api=api)
shots = found['_items']
thumb_placeholder = flask.url_for('static_attract', filename='assets/img/placeholder.jpg')
for shot in shots:
picture = get_file(shot.picture, api=api)
if picture:
shot._thumbnail = next((var.link for var in picture.variations
if var.size == 't'), thumb_placeholder)
else:
shot._thumbnail = thumb_placeholder
# The placeholder can be shown quite small, but otherwise the aspect ratio of
# the actual thumbnail should be taken into account. Since it's different for
# each project, we can't hard-code a proper height.
shot._thumbnail_height = '30px' if shot._thumbnail is thumb_placeholder else 'auto'
tasks_for_shots = current_attract.shot_manager.tasks_for_nodes(
shots,
attract_props.task_types.attract_shot,
)
# Append the task type onto which 'other' tasks are mapped.
task_types = attract_props.task_types.attract_shot + [None]
shots, tasks_for_shots, task_types_for_template = routes_common.for_project(
node_type_shot['name'],
task_types,
project, attract_props, task_id, shot_id)
# Some aggregated stats
stats = {
@@ -69,7 +41,7 @@ def for_project(project, attract_props, task_id=None, shot_id=None):
return render_template('attract/shots/for_project.html',
shots=shots,
tasks_for_shots=tasks_for_shots,
task_types=task_types,
task_types=task_types_for_template,
open_task_id=task_id,
open_shot_id=shot_id,
project=project,
@@ -83,14 +55,7 @@ def view_shot(project, attract_props, shot_id):
if not request.is_xhr:
return for_project(project, attract_props, shot_id=shot_id)
# Shot list is public, shot details are not.
if not flask_login.current_user.has_role(*ROLES_REQUIRED_TO_VIEW_ITEMS):
raise wz_exceptions.Forbidden()
api = pillar_api()
shot = pillarsdk.Node.find(shot_id, api=api)
node_type = project.get_node_type(node_type_shot['name'])
shot, node_type = routes_common.view_node(project, shot_id, node_type_shot['name'])
return render_template('attract/shots/view_shot_embed.html',
shot=shot,

View File

@@ -34,18 +34,21 @@ function item_open(item_id, item_type, pushState, project_url)
$('[id^="' + item_type + '-"]').removeClass('active');
$('#' + item_type + '-' + item_id).addClass('active');
// Special case to highlight the shot row when opening task in shot context
if (ProjectUtils.context() == 'shot' && item_type == 'task'){
// Special case to highlight the shot row when opening task in shot or asset context
var pu_ctx = ProjectUtils.context();
var pc_ctx_shot_asset = (pu_ctx == 'shot' || pu_ctx == 'asset');
if (pc_ctx_shot_asset && item_type == 'task'){
$('[id^="shot-"]').removeClass('active');
$('[id^="asset-"]').removeClass('active');
$('#task-' + item_id).closest('.table-row').addClass('active');
}
var item_url = '/attract/' + project_url + '/' + item_type + 's/' + item_id;
var push_url = item_url;
if (ProjectUtils.context() == 'shot' && item_type == 'task'){
push_url = '/attract/' + project_url + '/shots/with-task/' + item_id;
if (pc_ctx_shot_asset && item_type == 'task'){
push_url = '/attract/' + project_url + '/' + pu_ctx + 's/with-task/' + item_id;
}
item_url += '?context=' + ProjectUtils.context();
item_url += '?context=' + pu_ctx;
statusBarSet('default', 'Loading ' + item_type + '…');
@@ -94,6 +97,11 @@ function shot_open(shot_id)
item_open(shot_id, 'shot');
}
function asset_open(asset_id)
{
item_open(asset_id, 'asset');
}
window.onpopstate = function(event)
{
var state = event.state;
@@ -102,26 +110,26 @@ window.onpopstate = function(event)
}
/**
* Create a task and show it in the #item-details div.
* NOTE: Not used at the moment, we're creating shots via Blender's VSE
* Create a asset and show it in the #item-details div.
* NOTE: Not used at the moment, we're creating assets via Blender's VSE
*/
function shot_create(project_url)
function asset_create(project_url)
{
if (project_url === undefined) {
throw new ReferenceError("shot_create(" + project_url+ ") called.");
throw new ReferenceError("asset_create(" + project_url+ ") called.");
}
var url = '/attract/' + project_url + '/shots/create';
var url = '/attract/' + project_url + '/assets/create';
data = {
project_url: project_url
};
$.post(url, data, function(shot_data) {
shot_open(shot_data.shot_id);
$.post(url, data, function(asset_data) {
asset_open(asset_data.asset_id);
})
.fail(function(xhr) {
if (console) {
console.log('Error creating task');
console.log('Error creating asset');
console.log('XHR:', xhr);
}
$('#item-details').html(xhr.responseText);
@@ -156,17 +164,17 @@ function task_add(shot_id, task_id, task_type)
<span class="due_date">-</span>\
</a>\
');
} else if (context == 'shot') {
} else if (context == 'shot' || context == 'asset') {
if (shot_id === undefined) {
throw new ReferenceError("task_add(" + shot_id + ", " + task_id + ", " + task_type + ") called in shot context.");
throw new ReferenceError("task_add(" + shot_id + ", " + task_id + ", " + task_type + ") called in " + context + " context.");
}
var $shot_cell = $('#shot-' + shot_id + ' .table-cell.task-type.' + task_type);
var url = '/attract/' + project_url + '/shots/with-task/' + task_id;
var $list_cell = $('#' + context + '-' + shot_id + ' .table-cell.task-type.' + task_type);
var url = '/attract/' + project_url + '/' + context + 's/with-task/' + task_id;
/* WARNING: This is a copy of an element of attract/shots/for_project #task-list.col-list
* If that changes, change this too. */
$shot_cell.append('\
$list_cell.append('\
<a class="status-todo task-link active"\
title="-save your task first-"\
href="' + url + '"\
@@ -175,7 +183,9 @@ function task_add(shot_id, task_id, task_type)
</a>\
');
$shot_cell.find('.task-add.task-add-link').addClass('hidden');
$list_cell.find('.task-add.task-add-link').addClass('hidden');
} else {
if (console) console.log('task_add: not doing much in context', context);
}
}
@@ -322,6 +332,36 @@ function shot_save(shot_id, shot_url) {
});
}
function asset_save(asset_id, asset_url) {
return attract_form_save('shot_form', 'asset-' + asset_id, asset_url, {
done: function($asset, saved_asset) {
// Update the asset list.
// NOTE: this is tightly linked to the HTML of the asset list in for_project.jade.
$('.asset-name-' + saved_asset._id).text(saved_asset.name).flashOnce();
$asset.find('span.name').text(saved_asset.name);
$asset.find('span.due_date').text(moment().to(saved_asset.properties.due_date));
$asset.find('span.status').text(saved_asset.properties.status.replace('_', ' '));
$asset
.removeClassPrefix('status-')
.addClass('status-' + saved_asset.properties.status)
.flashOnce()
;
asset_open(asset_id);
},
fail: function($item, xhr_or_response_data) {
if (xhr_or_response_data.status == 412) {
// TODO: implement something nice here. Just make sure we don't throw
// away the user's edits. It's up to the user to handle this.
} else {
$('#item-details').html(xhr_or_response_data.responseText);
}
},
type: 'asset'
});
}
function task_delete(task_id, task_etag, task_delete_url) {
if (task_id === undefined || task_etag === undefined || task_delete_url === undefined) {
throw new ReferenceError("task_delete(" + task_id + ", " + task_etag + ", " + task_delete_url + ") called.");

View File

@@ -0,0 +1,142 @@
| {% extends 'attract/layout.html' %}
| {% block bodyattrs %}{{ super() }} data-context='asset'{% endblock %}
| {% block page_title %}Assets - {{ project.name }}{% endblock %}
| {% block body %}
#col_main
.col_header.task-list-header
| {{ assets | count }} assets
a.task-project(href="{{url_for('projects.view', project_url=project.url)}}") {{ project.name }}
a#task-add(href="javascript:asset_create('{{ project.url }}');") + Create Asset
#shot-list
.table
.table-head
.table-row
.table-cell.asset-status
.table-cell.asset-thumbnail
span.collapser.thumbnails(title="Collapse thumbnails") Thumbnail
.table-cell.asset-name
span.collapser(title="Collapse name column") Name
| {% for task_type in task_types %}
.table-cell.task-type(class="{{ task_type }}")
span.collapser(title="Collapse {{ task_type or 'Other' }} column") {{ task_type or 'other' }}
| {% endfor %}
.table-body
| {% for asset in assets %}
.table-row(
id="asset-{{ asset._id }}",
class="status-{{ asset.properties.status }} {{ asset.properties.used_in_edit | yesno(' ,not-in-edit, ') }}")
.table-cell.asset-status(
title="Status: {{ asset.properties.status | undertitle }}")
.table-cell.asset-thumbnail
a(
data-asset-id="{{ asset._id }}",
href="{{ url_for('attract.assets.perproject.view_asset', project_url=project.url, asset_id=asset._id) }}",
class="status-{{ asset.properties.status }} asset-link")
img(src="{{ asset._thumbnail }}",
alt="Thumbnail",
style='width: 110px; height: {{ asset._thumbnail_height }}')
.table-cell.asset-name
a(
data-asset-id="{{ asset._id }}",
href="{{ url_for('attract.assets.perproject.view_asset', project_url=project.url, asset_id=asset._id) }}",
class="status-{{ asset.properties.status }} asset-link")
span(class="asset-name-{{ asset._id }}") {{ asset.name }}
| {% for task_type in task_types %}
.table-cell.task-type(class="{{ task_type }}")
| {% for task in tasks_for_assets[asset._id][task_type] %}
a(
data-task-id="{{ task._id }}",
id="task-{{ task._id }}",
href="{{ url_for('attract.assets.perproject.with_task', project_url=project.url, task_id=task._id) }}",
class="status-{{ task.properties.status }} task-link",
title="{{ task.properties.status | undertitle }} task: {{ task.name }}")
| {# First letter of the status. Disabled until we provide the user setting to turn it off
span {{ task.properties.status[0] }}
| #}
| {% endfor %}
//- Dirty hack, assume a user can create a task for a asset if they can edit the asset.
| {% if 'PUT' in asset.allowed_methods %}
a.task-add(
title="Add a new '{{ task_type }}' task",
class="task-add-link {% if tasks_for_assets[asset._id][task_type] %}hidden{% endif %}"
href="javascript:task_create('{{ asset._id }}', '{{ task_type }}');")
i.pi-plus
| Task
| {% endif %}
| {% endfor %}
| {% endfor %}
.col-splitter
#col_right
.col_header
span.header_text
#status-bar
#item-details
.item-details-empty
| Select a asset or Task
| {% endblock %}
| {% block footer_scripts %}
script.
{% if open_task_id %}
$(function() { item_open('{{ open_task_id }}', 'task', false); });
{% endif %}
{% if open_asset_id %}
$(function() { item_open('{{ open_asset_id }}', 'asset', false); });
{% endif %}
var same_cells;
/* Collapse columns by clicking on the title */
$('.table-head .table-cell span.collapser').on('click', function(e){
e.stopPropagation();
/* We need to find every cell matching the same classes */
same_cells = '.' + $(this).parent().attr('class').split(' ').join('.');
$(same_cells).hide();
/* Add the spacer which we later click to expand */
$('<div class="table-cell-spacer ' + $(this).text() + '" title="Expand ' + $(this).text() + '"></div>').insertAfter(same_cells);
});
$('body').on('click', '.table-cell-spacer', function(){
/* We need to find every cell matching the same classes */
same_cells = '.' + $(this).prev().attr('class').split(' ').join('.');
$(same_cells).show();
$(same_cells).next().remove();
});
$('.table-body .table-cell').mouseenter(function(){
same_cells = '.' + $(this).attr('class').split(' ').join('.');
$('.table-head ' + same_cells).addClass('highlight');
}).mouseleave(function(){
same_cells = '.' + $(this).attr('class').split(' ').join('.');
$('.table-head ' + same_cells).removeClass('highlight');
});
$('.table-head .table-cell').mouseenter(function(){
same_cells = '.' + $(this).attr('class').split(' ').join('.');
$('.table-body ' + same_cells).addClass('highlight');
}).mouseleave(function(){
same_cells = '.' + $(this).attr('class').split(' ').join('.');
$('.table-body ' + same_cells).removeClass('highlight');
});
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/clipboard.min.js')}}")
script(src="{{ url_for('static_attract', filename='assets/js/vendor/jquery-resizable.min.js')}}")
script.
$("#col_main").resizable({
handleSelector: ".col-splitter",
resizeHeight: false
});
// Set height of asset-list and item details so we can scroll inside them
$(window).on('load resize', function(){
var window_height = $(window).height() - 50; // header is 50px
$('#asset-list').css({'height': window_height});
$('#item-details').css({'height': window_height});
});
| {% endblock footer_scripts %}

View File

@@ -0,0 +1,124 @@
.attract-box.shot.with-status(class="status-{{ asset.properties.status }}")
form#shot_form(onsubmit="return asset_save('{{asset._id}}', '{{ url_for('attract.assets.perproject.save', project_url=project['url'], asset_id=asset._id) }}')")
input(type='hidden',name='_etag',value='{{ asset._etag }}')
| {% if 'PUT' in asset.allowed_methods %}
input.item-name(
name="name",
type="text",
placeholder='Asset name',
value="{{ asset.name | hide_none }}")
| {% else %}
span.item-name {{ asset.name | hide_none }}
| {% endif %}
button.copy-to-clipboard.btn.item-id(
style="margin-left: auto",
name="Copy to Clipboard",
type="button",
data-clipboard-text="{{ asset._id }}",
title="Copy ID to clipboard")
| ID
| {% if 'PUT' in asset.allowed_methods %}
.input-group
textarea#item-description.input-transparent(
name="description",
type="text",
rows=1,
placeholder='Description') {{ asset.description | hide_none }}
.input-group
label(for="item-status") Status:
select#item-status.input-transparent(
name="status")
| {% for status in asset_node_type.dyn_schema.status.allowed %}
| <option value="{{ status }}" {% if status == asset.properties.status %}selected{% endif %}>{{ status | undertitle }}</option>
| {% endfor %}
.input-group
textarea#item-notes.input-transparent(
name="notes",
type="text",
rows=1,
placeholder='Notes') {{ asset.properties.notes | hide_none }}
.input-group-separator
.input-group
button#item-save.btn.btn-default.btn-block(type='submit')
i.pi-check
| Save Asset
| {% else %}
//- NOTE: read-only versions of the fields above.
| {% if asset.description %}
p.item-description {{ asset.description | hide_none }}
| {% endif %}
.table.item-properties
.table-body
.table-row.properties-status.js-help(
data-url="{{ url_for('attract.help', project_url=project.url) }}")
.table-cell Status
.table-cell(class="status-{{ asset.properties.status }}")
| {{ asset.properties.status | undertitle }}
| {% if asset.properties.notes %}
.table-row
.table-cell Notes
.table-cell
| {{ asset.properties.notes | hide_none }}
| {% endif %}
| {% endif %}
#item-view-feed
#activities
#comments-embed
| {% if config.DEBUG %}
.debug-info
a.debug-info-toggle(role='button',
data-toggle='collapse',
href='#debug-content',
aria-expanded='false',
aria-controls='debug-content')
i.pi-info
| Debug Info
#debug-content.collapse
pre.
{{ asset.to_dict() | pprint }}
| {% endif %}
script.
var clipboard = new Clipboard('.copy-to-clipboard');
clipboard.on('success', function(e) {
statusBarSet('info', 'Copied asset ID to clipboard', 'pi-check');
});
var activities_url = "{{ url_for('.activities', project_url=project.url, asset_id=asset['_id']) }}";
loadActivities(activities_url); // from 10_tasks.js
loadComments("{{ url_for('nodes.comments_for_node', node_id=asset['_id']) }}");
$('body').on('pillar:comment-posted', function(e, comment_node_id) {
loadActivities(activities_url)
.done(function() {
$('#' + comment_node_id).scrollHere();
});
});
$('.js-help').openModalUrl('Help', "{{ url_for('attract.help', project_url=project.url) }}");
{% if 'PUT' in asset.allowed_methods %}
/* Resize textareas */
var textAreaFields = $('#item-description, #item-notes');
textAreaFields.each(function(){
$(this)
.autoResize()
.blur();
});
$('#item-status').change(function(){
$("#item-save").trigger( "click" );
});
{% endif %}

View File

@@ -41,6 +41,9 @@ html(lang="en")
li
a.navbar-item.shots(href="{{ url_for('attract.shots.perproject.index', project_url=project.url) }}",
title='Shots for project {{ project.name }}') S
li
a.navbar-item.shots(href="{{ url_for('attract.assets.perproject.index', project_url=project.url) }}",
title='Assets for project {{ project.name }}') A
| {% else %}
| {% if current_user.is_authenticated %}
li