diff --git a/attract/__init__.py b/attract/__init__.py index 6c456a9..08f0d0d 100644 --- a/attract/__init__.py +++ b/attract/__init__.py @@ -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, ] diff --git a/attract/shots_and_assets/routes_assets.py b/attract/shots_and_assets/routes_assets.py new file mode 100644 index 0000000..da811ea --- /dev/null +++ b/attract/shots_and_assets/routes_assets.py @@ -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='//assets') +log = logging.getLogger(__name__) + + +@perproject_blueprint.route('/', endpoint='index') +@perproject_blueprint.route('/with-task/', 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('/') +@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('/', 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('//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) diff --git a/attract/shots_and_assets/routes_common.py b/attract/shots_and_assets/routes_common.py new file mode 100644 index 0000000..9f37377 --- /dev/null +++ b/attract/shots_and_assets/routes_common.py @@ -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//{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 diff --git a/attract/shots_and_assets/routes.py b/attract/shots_and_assets/routes_shots.py similarity index 68% rename from attract/shots_and_assets/routes.py rename to attract/shots_and_assets/routes_shots.py index 1475f5f..3b3d572 100644 --- a/attract/shots_and_assets/routes.py +++ b/attract/shots_and_assets/routes_shots.py @@ -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='//shots') log = logging.getLogger(__name__) @@ -23,40 +25,10 @@ log = logging.getLogger(__name__) @perproject_blueprint.route('/with-task/', 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, diff --git a/src/scripts/tutti/10_tasks.js b/src/scripts/tutti/10_tasks.js index 12114d9..f8e76aa 100644 --- a/src/scripts/tutti/10_tasks.js +++ b/src/scripts/tutti/10_tasks.js @@ -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) -\ \ '); - } 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('\ \ '); - $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."); diff --git a/src/templates/attract/assets/for_project.jade b/src/templates/attract/assets/for_project.jade new file mode 100644 index 0000000..e69ce17 --- /dev/null +++ b/src/templates/attract/assets/for_project.jade @@ -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 */ + $('
').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 %} diff --git a/src/templates/attract/assets/view_asset_embed.jade b/src/templates/attract/assets/view_asset_embed.jade new file mode 100644 index 0000000..2eb9308 --- /dev/null +++ b/src/templates/attract/assets/view_asset_embed.jade @@ -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 %} + | + | {% 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 %} diff --git a/src/templates/attract/layout.jade b/src/templates/attract/layout.jade index 6810f29..c137aa5 100644 --- a/src/templates/attract/layout.jade +++ b/src/templates/attract/layout.jade @@ -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