123 Commits

Author SHA1 Message Date
Anna Sirota
94c63d9b4e Pin a different version of wheel 2021-03-18 17:15:30 +01:00
Anna Sirota
bbf0f791b5 Pin setuptools and wheels as well 2021-03-18 16:58:13 +01:00
Anna Sirota
f37fcd3765 Pin cryptography 2021-03-18 13:16:04 +01:00
864d0002f6 Update gulp-sass 2020-07-23 18:45:02 +02:00
50782556f6 Replaced Gravatar with self-hosted avatars
Requires Pillar 47474ac936ffb1d179161c8a3cac5d20e6005659
2019-05-31 17:03:45 +02:00
73fd86e28c Converted Gulp 3.9 → 4.0, removed livereload, and fixed security issues
All done in one go, because things wouldn't build otherwise.
2019-05-31 13:48:09 +02:00
c5722d1316 Removed and gitignored poetry.lock
The poetry.lock files are only relevant for repeatable deployments,
and the one in this project isn't used for that (only the Blender
Cloud project file is used, and that's still there).
2019-05-23 16:23:45 +02:00
e33297e3f5 Updated dependencies
- SVN 0.3.43 → ~0.3 (which installed 0.3.46 and needed some fixes)
- mkdocs 0.17.2 → ~1.0 (which installed 1.0.4)
- mkdocs-material 2.2.2 → ~4.2 (which installed 4.2.0)
2019-05-14 14:33:55 +02:00
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
14530d76a9 README: added mention of Poetry and how to use it 2019-05-10 15:04:32 +02:00
e2dc9b8d33 Moved to Poetry 2019-04-26 12:23:44 +02:00
e38c577bcb Clean up: Whitespace 2019-04-04 11:34:13 +02:00
a376beb143 Use kebab-case for vue names
https://vuejs.org/v2/guide/components-custom-events.html#Event-Names
2019-04-04 11:33:43 +02:00
383feaa4d0 Silence warning about changing prop value 2019-04-04 10:18:24 +02:00
784265715f Whitespace cleanup 2019-04-03 17:51:28 +02:00
977a9e2640 Better initial component values 2019-04-03 17:50:15 +02:00
ec4cad5e5b Fix wrong prop type 2019-04-03 17:49:16 +02:00
f1354b9837 Fix: Fail to render if parent was deleted 2019-04-02 14:09:20 +02:00
23e0e55de9 Documentation of es6 transcompile and packaging 2019-03-29 10:44:04 +01:00
120ea251bd Refactored Date columns to have a common base 2019-03-28 14:36:30 +01:00
b43bb8a696 Add Created and Updated column 2019-03-28 12:48:15 +01:00
a7c1f5aa39 Store filter/column settings in localStorage
The filter and column settings in tables are stored per project and
context in the browsers localStorage. This makes the table keep the
settings even if the browser is refreshed or restarted.

The table emits a "componentStateChanged" event containing the tables
current state (filter/column settings) which then is saved by the top
level component.
2019-03-28 10:29:13 +01:00
67d1e05d10 Update package-lock.json
The current packages where failing to build libsass on macOS.
2019-03-27 14:27:07 +01:00
d2459c451c Make sure sort buttons is always clickable
Hide part overflow of column label if there is not enough room
2019-03-22 14:10:18 +01:00
c86503e165 Add missing tooltips in table 2019-03-22 14:07:29 +01:00
0c96d3eda1 Add css class per task type to table columns 2019-03-22 14:06:53 +01:00
b2801492fe Move table css from attract to pillar repo 2019-03-20 15:12:19 +01:00
479b844174 Generalized table to not depend on project id 2019-03-15 10:18:23 +01:00
4f5eee6705 Added comments and minor refactoring 2019-03-14 10:54:43 +01:00
434cdb35a0 Attract multi edit: Disable save button during save 2019-03-13 15:25:32 +01:00
bae39ce01d Attract multi edit: Edit multiple tasks/shots/assets at the same time
For the user:
Ctrl + L-Mouse to select multiple tasks/shots/assets and then edit
the nodes as before. When multiple items are selected a chain icon
can be seen in editor next to the fields. If the chain is broken
it indicates that the values are not the same on all the selected
items.

When a field has been edited it will be marked with a green background
color.

The items are saved one by one in parallel. This means that one item
could fail to be saved, while the others get updated.

For developers:
The editor and activities has been ported to Vue. The table and has
been updated to support multi select.

MultiEditEngine is the core of the multi edit. It keeps track of
what values differs and what has been edited.
2019-03-13 13:53:40 +01:00
f4c7101427 Attract: Don't show add task button in cell if there are tasks
Requested by @fsiddi
2019-02-20 16:46:02 +01:00
d1713f93b3 Notifications regression: Notifications not created
Notifications for when someone posted a comment on your node
was not created.

Root cause was that default values defined in schema was not set,
resulting in activity subscriptions not being active.
There were 2 bugs preventing them to be set:
* The way the caching of markdown as html was implemented caused
  default values not to be set.
* Eve/Cerberus regression causes nested default values to fail
  https://github.com/pyeve/eve/issues/1174

Also, a 3rd bug caused nodes without a parent not to have a
subscription.

Migration scripts:
How markdown fields is cached has changed, and unused properties
of attachments has been removed.
./manage.py maintenance replace_pillar_node_type_schemas

Set the default values of activities-subscription
./manage.py maintenance fix_missing_activities_subscription_defaults
2019-02-19 14:16:28 +01:00
fccf6eb7a6 Regression after ui update: Found by sentry 2019-02-13 15:09:49 +01:00
fbe4e53e50 Regression fix: Highlight selected task "dot" in shots/assets table
Quick hack to restore functionality. Known limitations:
* A direct link to task will not highlight it
* When a new task is created it will not be highlighted
* Selected row will not be highlighted
2019-02-13 13:03:17 +01:00
ac8a6284d4 Vue Attract: Default sort shots by cut_in_timeline_in_frames 2019-02-12 12:59:01 +01:00
5e73720d91 Vue Attract: Sort/filterable table based on Vue
Initial commit implementing sortable and filterable tables for attract
using Vue.
2019-02-12 09:08:37 +01:00
66212ec5fa Navigation: Unified cloud navigation
* Removed main drop down menu
* Added "My cloud" to user menu
* Attract/Flamenco is found under Production Tools menu
* Attract/Flamenco has the same navigation as its project
2019-02-07 14:45:55 +01:00
763866787d Gulp fix for NodeJS 10 2019-01-04 14:20:57 +01:00
11652dd5cf Vue Comments: Comments ported to Vue + DnD fileupload
* Drag and drop files to comment editor to add a file attachment
* Using Vue to render comments

Since comments now has attachments we need to update the schemas
./manage.py maintenance replace_pillar_node_type_schemas
2018-12-12 11:45:47 +01:00
755091f4e5 Use correct permission format for gulp-chmod 2018-09-19 14:45:30 +02:00
552c05d031 Update pillar hooks path to eve_hooks
Follow naming convention.
2018-09-17 09:13:42 +02:00
03a94271ae Attract icon in the setup for Attract button 2018-09-07 17:15:52 +02:00
765ccaa8c9 Minor adjustments to layout edit settings 2018-09-07 17:05:38 +02:00
ed457a125c Attract: class name fixes
To work with Bootstrap 4
2018-09-06 16:56:05 +02:00
70f49ed5bf Gulp: Only chmod files if in production 2018-09-06 15:47:05 +02:00
3e4eb91668 Gulp: fix broken path in Sass
And when defining gulp-uglify-es needs '.default' at the end.
2018-09-06 15:42:37 +02:00
0aa609817e NPM: Upgrade dependencies. 2018-09-06 15:35:06 +02:00
1ae23c7ce9 CSS: Build bootstrap as part of main.css 2018-09-06 15:34:53 +02:00
3e8e465c7f Layout: #status-bar is no longer used
We now use toastr for notifications.
2018-09-06 15:33:47 +02:00
5b578b58d8 Bootstrap and jQuery as NPM dependencies. 2018-09-06 15:31:48 +02:00
3dd3006452 Flask's RequestWrapper changed the json() function to a json property 2018-08-29 14:00:57 +02:00
bbf21f614d Docs: Fixed repository link 2018-06-27 11:16:20 +02:00
1be31bdb22 Fix issue with task shortcodes
Part of the code assumed shortcodes were globally unique, and another part
assumed the shortcodes are unique per project (the latter is correct).

Now the project ID is taken from the URL the Subversion hook pushes to.
2018-04-19 18:14:02 +02:00
b3e21d4b02 Added CLI command to SVN doc 2018-04-18 13:59:48 +02:00
6f9cb1fe38 Default to python3 in hook example (the script supports 2 and 3) 2018-04-18 13:53:49 +02:00
65aba61465 Added documentation for Subversion integration 2018-04-18 13:51:46 +02:00
bc47cf3f15 Markdown is no longer needed 2018-04-04 17:03:19 +02:00
bfec958b70 Fix commenting
It was missing Typewatch and csrf_token

Fixes T54518
2018-04-04 17:02:52 +02:00
08c2fbc517 Remove v=xxx code from static file URLs
Since Pillar d560f89704e3a6f4490df57712525048c469bed2 the URls for
static files are managed differently.
2018-03-23 17:30:14 +01:00
43668e43d2 Remove rsync_ui.sh and deploy.sh
Those files are no longer needed now that we deploy our source files
inside the Docker image. See Blender Cloud commit 94ef616593e85.
2018-02-06 11:03:29 +01:00
c6fb4c3184 Switch from .jade to .pug templates
No functional change, we simply align with the preprocessing pipeline 
used in Pillar.
2018-02-01 17:01:03 +01:00
cf41599e20 Fixed authentication issues in unit tests
The tests were logging in incorrectly, which came to light due to Pillar
commit 4b5a961e1422d8e976b2bf8bb9a4f91addf9bbec.
2018-02-01 11:43:14 +01:00
39a23a80c9 Remove -x as default option to py.test 2018-02-01 11:42:27 +01:00
47e0e6bc42 Use pillar.auth.current_user instead of flask_login.current_user 2018-02-01 11:42:13 +01:00
5ff0c9fde5 Use mass-attach of project pictures, and only when they are actually used 2018-01-31 14:49:38 +01:00
13dc6fea8e Don't do DB query to inspect current user.
This is especially important for IP ranges on Organizations, which can
change user roles on the fly in memory.
2018-01-24 14:57:30 +01:00
ca393af1b3 Add config_local.py to .gitignore file
This is useful when setting up Attract as a standalone application.
2017-12-13 11:19:51 +01:00
337e2db558 Add manage.py to run Pillar commands within the Attract repo
This is useful when setting up Attract as a standalone application.
2017-12-13 11:19:19 +01:00
0b3ea29d48 Switch from macros to blocks for navigation menus
For more information see commit a7693aa78dcf0a0a77e113f34afa63fb4f615441 in pillar.git
2017-12-13 11:12:17 +01:00
f7665a4060 Ignore docs build result 2017-12-12 16:48:56 +01:00
7fd4649a56 Introducing docs 📚 2017-12-12 16:48:56 +01:00
da0f606110 Merge branch 'production' 2017-12-12 11:15:53 +01:00
5b96aa4fdb Revert "Reverting "Removed attract-user role.""
This reverts commit e84e952169.
2017-12-12 11:15:47 +01:00
a866008be1 Also grant attract-use capability to subscriber/demo roles 2017-12-12 11:09:33 +01:00
e84e952169 Reverting "Removed attract-user role."
Temporarily reverting b40b6dadd2 due to an issue found in roles and capabilities.
2017-12-08 17:50:24 +01:00
b40b6dadd2 Removed attract-user role.
It's no longer used now that we're using the capabilities system.
2017-12-07 17:07:53 +01:00
4fa18d4454 Gulp: added 'cleanup' task that erases all gulp-generated files.
This runs automatically when using --production
2017-09-28 15:35:15 +02:00
5c58ced224 Gulp: replaced hardcoded paths with variables. 2017-09-28 15:34:28 +02:00
412bd3a935 Gulp: fixed license expression 2017-09-28 15:33:54 +02:00
eb954208b2 Early reject when user is anonymous.
This cuts down on info-level log entries, and prevents some Mongo queries.
2017-09-18 14:04:52 +02:00
c7b83d2d8b Using capabilities instead of roles for access control. 2017-08-24 14:21:33 +02:00
c4071c1e03 Removed some unused imports 2017-08-24 14:21:23 +02:00
0612ed15a6 Allow deletion of tasks by non-admin users. 2017-07-13 17:29:16 +02:00
b918151c94 Updated license and url in package.json 2017-07-13 16:15:00 +02:00
25fcfea62f Remember last-visited Attract project in session
This allows us to keep rendering the "Shots" and "Assets" links in the
sidebar, even when someone navigates away from the project scope.
2017-06-16 12:02:39 +02:00
59505d3233 Fixed Attract link not showing up in sidebar 2017-06-16 11:55:30 +02:00
c69aeb03dc Hide Attract links & project settings for non-attract-users 2017-06-15 16:26:17 +02:00
62795b4007 Removed snippet we're not going to use in the forseeable future anyway. 2017-06-15 12:50:41 +02:00
50ae411575 Only users with attract-user role can use Attract
Subscribers without that role still have read-only access to Attract,
assuming they have access to the project at all.

NOTE: this only handles the web interface. API calls are still governed
by the nodes permission system, which doesn't currently allow these kinds
of role-based user checks.
2017-06-15 12:50:28 +02:00
f4a06c3271 Declare user roles introduced by Attract 2017-06-15 11:32:05 +02:00
bf9a73ff00 Fixed project property loss when setting up for Attract
This was caused by attract_project_view() not passing the full project to
the decorated function. Now you can pass full_project=True to avoid
projections at all, and get the full thing.
2017-06-15 11:07:27 +02:00
9ea75c30e3 Added project settings allowing setup + editing task types.
- Attract added to Project Settings screen
- setting up project for Attract
- editing shot/asset task types

To do: add checks that the user is allowed to use Attract in the first
place.
2017-06-15 11:07:27 +02:00
725f93175c Added Attract project sidebar 2017-06-15 11:07:14 +02:00
ab72357336 scrollHeaderHorizontal: don't use hardcoded class 2017-06-14 18:31:09 +02:00
0868449209 Lists: Fixed header should let clicks go through 2017-06-14 18:27:16 +02:00
f6d2a477eb Lists: Don't wrap list's extra info (shots/tasks/assets count) 2017-06-14 18:27:16 +02:00
28edb86aeb Shots/Assets list: Fix fixed table header not resizing when adjusting col_main width 2017-06-14 18:27:16 +02:00
c38203ba63 item_open: use toastr notification when failing 2017-06-14 18:27:16 +02:00
9d59aefd80 Upgrade jquery-resizable to 0.20
https://github.com/RickStrahl/jquery-resizable
2017-06-14 18:27:16 +02:00
f05ad37037 Asset/shot lists: use task types from project's Attract properties 2017-06-14 15:26:09 +02:00
23a2a8fd64 Use menu from Pillar 2017-06-14 14:58:48 +02:00
12c51fb3f5 Use notifications and menus from Pillar 2017-06-09 16:33:34 +02:00
a13ba17545 Fixed borked import 2017-05-31 10:49:03 +02:00
01973a2471 CSS Comments 2017-05-24 12:18:05 +02:00
a1391a6d1c View task in shot context 2017-05-19 12:30:58 +02:00
30397fc12f Pass full name of SVNer account as keyword arg 2017-05-18 15:38:30 +02:00
dc4cf6aecc Set explicit name for SVNer account
It now includes the project ID it's created for, and the fact that it's
a SVNer account, just for easy identification.
2017-05-18 10:03:12 +02:00
9d302d5124 Color status select 2017-05-10 23:34:46 +02:00
48ad75c461 Legend for colors in stats 2017-05-10 23:34:19 +02:00
2a88d9c309 Color status option 2017-05-10 16:53:51 +02:00
19f35e6713 Always show item-status cell on shots list 2017-05-10 16:02:57 +02:00
025b44bfac Compile Attract styles using config, utils and base styling from Pillar
De-duplicated code, yay!
2017-05-10 15:42:08 +02:00
fc08ab2bca Import _error sass from pillar 2017-04-11 16:47:37 +02:00
b5cbbad1ba Remove processing status when loading a task fails 2017-04-11 16:47:20 +02:00
b3bbb5e68b Removed illegal 'home_project' tag 2017-03-29 16:42:46 +02:00
e45f35f6f4 Fix listing of projects that use Attract
Wrong indentation when closing the loop through projects.

Closes T51028
2017-03-29 16:28:37 +02:00
3efb21484a Markdown was not included 2017-03-22 14:55:39 +01:00
ce00665cb2 Updated rsync_ui to deploy to different hosts 2017-03-10 09:52:58 +01:00
9376e40575 Auto-install -e attract
It uses ../attract instead of . so that it is a valid path from blender-cloud as well.
2017-03-07 14:23:49 +01:00
fa306c2821 Added missing link from dev to runtime requirement 2017-03-03 15:22:35 +01:00
4e8c735f6b Python 3.6 compatibility: Applied 2to3 2017-03-03 15:08:48 +01:00
cd17236428 Linked requirements to Pillar and Pillar-Python-SDK
Since Pillar is not pip-installable and also not properly versioned either,
I removed the 'pillar>=2.0' requirement from setup.py
2017-03-03 15:04:50 +01:00
140 changed files with 13489 additions and 2452 deletions

3
.gitignore vendored
View File

@@ -4,11 +4,14 @@
*.pyc
__pycache__
/config_local.py
/build
/.cache
/docs/site/
/*.egg-info/
/.eggs/
/node_modules/
/attract/templates/
/attract/static/assets/css/
/attract/static/assets/js/generated/
/poetry.lock

View File

@@ -9,20 +9,30 @@ from pillar.web.nodes.routes import url_for_node
import pillarsdk
import attract.auth
import attract.tasks
import attract.shots_and_assets
EXTENSION_NAME = 'attract'
# Roles required to view task or shot details.
ROLES_REQUIRED_TO_VIEW_ITEMS = {u'demo', u'subscriber', u'admin'}
class AttractExtension(PillarExtension):
has_project_settings = True
user_roles = {'org-attract'}
user_roles_indexable = {'org-attract'}
user_caps = {
'org-attract': {'attract-view', 'attract-use'},
'subscriber': {'attract-view', 'attract-use'},
'demo': {'attract-view', 'attract-use'},
'admin': {'attract-view', 'attract-use'},
}
def __init__(self):
self._log = logging.getLogger('%s.AttractExtension' % __name__)
self.task_manager = attract.tasks.TaskManager()
self.shot_manager = attract.shots_and_assets.ShotAssetManager()
self.auth = attract.auth.Auth()
@property
def name(self):
@@ -149,22 +159,45 @@ 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
def sidebar_links(self, project):
from pillar.api.utils import str2id
if not self.is_attract_project(project):
return ''
# Temporarily disabled until Attract is nicer to look at.
return ''
# return flask.render_template('attract/sidebar.html',
# project=project)
if not self.auth.current_user_may(auth.Actions.VIEW, str2id(project['_id'])):
return ''
return flask.render_template('attract/sidebar.html',
project=project)
@property
def has_project_settings(self) -> bool:
return self.auth.current_user_is_attract_user()
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.
@@ -185,7 +218,7 @@ class AttractExtension(PillarExtension):
'context_object': node_id},
],
},
'sort': [('_created', -1)],
'sort': [('_id', 1)], # Sort by creation, _id is incremental.
'max_results': max_results,
'page': page,
}, api=api)
@@ -221,11 +254,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."""

103
attract/auth.py Normal file
View File

@@ -0,0 +1,103 @@
import enum
import flask
import attr
import bson
from pillar import attrs_extra
# Having any of these methods on a project means you can use Attract.
# Prerequisite: the project is set up for Attract and has a Manager assigned to it.
PROJECT_METHODS_TO_USE_ATTRACT = {'PUT'}
class Actions(enum.Enum):
VIEW = 'view'
USE = 'use'
# Required capability for a given action.
req_cap = {
Actions.VIEW: 'attract-view',
Actions.USE: 'attract-use',
}
@attr.s
class Auth(object):
"""Handles authorization for Attract."""
_log = attrs_extra.log('%s.Auth' % __name__)
Actions = Actions # this allows using current_attract.auth.Actions
def current_user_is_attract_user(self) -> bool:
"""Returns True iff the current user has Attract User role."""
from pillar.auth import current_user
return current_user.has_cap('attract-use')
def user_is_attract_user(self, user_id: bson.ObjectId) -> bool:
"""Returns True iff the user has Attract User role."""
from pillar import current_app
from pillar.auth import UserClass
assert isinstance(user_id, bson.ObjectId)
# TODO: move role checking code to Pillar.
users_coll = current_app.db('users')
db_user = users_coll.find_one({'_id': user_id}, {'roles': 1})
if not db_user:
self._log.debug('user_is_attract_user: User %s not found', user_id)
return False
user = UserClass.construct('', db_user)
return user.has_cap('attract-use')
def current_user_may(self, action: Actions, project_id: bson.ObjectId = None) -> bool:
"""Returns True iff the user is authorised to use/view Attract on the current project.
Requires that determine_user_rights() was called before.
"""
try:
attract_rights = flask.g.attract_rights
except AttributeError:
if not project_id:
self._log.error('current_user_may() called without previous call '
'to current_user_rights()')
return False
self.determine_user_rights(project_id)
attract_rights = flask.g.attract_rights
return action in attract_rights
def determine_user_rights(self, project_id: bson.ObjectId):
"""Updates g.attract_rights to reflect the current user's usage rights.
g.attract_rights is a frozenset that contains zero or more Actions.
"""
from pillar.auth import current_user
from pillar.api.projects.utils import user_rights_in_project
if current_user.is_anonymous:
self._log.debug('Anonymous user never has access to Attract.')
flask.g.attract_rights = frozenset()
return
rights = set()
for action in Actions:
cap = req_cap[action]
if current_user.has_cap(cap):
rights.add(action)
# TODO Sybren: possibly split this up into a manager-fetching func + authorisation func.
# TODO: possibly store the user rights on the current project in the current_user object?
allowed_on_proj = user_rights_in_project(project_id)
if not allowed_on_proj.intersection(PROJECT_METHODS_TO_USE_ATTRACT):
rights.discard(Actions.USE)
flask.g.attract_rights = frozenset(rights)

View File

@@ -5,7 +5,8 @@ import logging
from flask import current_app
from flask_script import Manager
from pillar.cli import manager, create_service_account
from pillar.cli import manager
from pillar.cli.setup import create_service_account
from pillar.api.utils import authentication
import attract.setup
@@ -46,7 +47,9 @@ def create_svner_account(email, project_url):
log.error('Unable to find project url=%s', project_url)
return 1
account, token = create_service_account(email, [u'svner'], {'svner': {'project': proj['_id']}})
proj_id = proj['_id']
account, token = create_service_account(email, ['svner'], {'svner': {'project': proj_id}},
full_name=f'SVNer for project {proj_id}')
return account, token
manager.add_command("attract", manager_attract)

View File

@@ -2,7 +2,7 @@ import logging
import flask
from pillar.api.nodes import only_for_node_type_decorator
from pillar.api.nodes.eve_hooks import only_for_node_type_decorator
import pillar.api.activities
import pillar.api.utils.authentication

View File

@@ -4,7 +4,7 @@ import logging
import flask
from pillar.api.nodes import only_for_node_type_decorator
from pillar.api.nodes.eve_hooks import only_for_node_type_decorator
from .node_types import NODE_TYPES
log = logging.getLogger(__name__)

View File

@@ -16,7 +16,7 @@ def find_for_shot(project, node):
@register_node_finder(node_type_task['name'])
def find_for_task(project, node):
parent = node.get(u'parent') if isinstance(node, dict) else node.parent
parent = node.get('parent') if isinstance(node, dict) else node.parent
if parent:
endpoint = 'attract.shots.perproject.with_task'
else:

View File

@@ -1,18 +1,22 @@
import functools
import logging
from flask import Blueprint, render_template, redirect, url_for
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, session
import flask_login
import werkzeug.exceptions as wz_exceptions
from pillar.web.utils import attach_project_pictures
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__)
@@ -32,13 +36,14 @@ def index():
# TODO: add projections.
projects = current_attract.attract_projects()
for project in projects['_items']:
attach_project_pictures(project, api)
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']}
@@ -46,7 +51,7 @@ def index():
'where': {
'project': {'$in': list(id_to_proj.keys())},
},
'sort': [('_created', -1)],
'sort': [('_id', 1)], # Sort by creation, _id is incremental.
'max_results': 20,
}, api=api)
@@ -59,17 +64,20 @@ def index():
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)
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=None, extension_props=False):
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
@@ -81,14 +89,11 @@ def attract_project_view(extra_project_projections=None, extension_props=False):
:param extra_project_projections: extra projections to use on top of the ones already
used by this decorator.
:type extra_project_projections: dict
:param extension_props: whether extension properties should be included. Includes them
in the projections, and verifies that they are there.
:type extension_props: bool
:param full_project: skip projections altogether, fetching the whole project.
"""
from . import EXTENSION_NAME
if callable(extra_project_projections):
raise TypeError('Use with @attract_project_view() <-- note the parentheses')
@@ -96,14 +101,14 @@ def attract_project_view(extra_project_projections=None, extension_props=False):
'_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)
if extension_props:
projections['extension_props.%s' % EXTENSION_NAME] = 1
def decorator(wrapped):
@functools.wraps(wrapped)
@@ -114,11 +119,16 @@ def attract_project_view(extra_project_projections=None, extension_props=False):
# 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': projections},
projection_param,
api=api)
is_attract = current_attract.is_attract_project(project,
@@ -126,6 +136,15 @@ def attract_project_view(extra_project_projections=None, extension_props=False):
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)
@@ -137,8 +156,8 @@ def attract_project_view(extra_project_projections=None, extension_props=False):
@blueprint.route('/<project_url>')
@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 +171,91 @@ 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
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

View File

@@ -1,13 +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.
"""
from __future__ import print_function, division
import copy
import logging
import typing
from bson import ObjectId
from eve.methods.put import put_internal
@@ -110,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)

View File

@@ -5,7 +5,6 @@ import logging
import attr
import flask
import flask_login
from eve.methods.put import put_internal
from werkzeug import exceptions as wz_exceptions
@@ -16,30 +15,31 @@ from pillar.web.system_util import pillar_api
from pillar.api.nodes.custom import register_patch_handler
from pillar.api.utils import node_setattr
from pillar import attrs_extra
from pillar.auth import current_user
from attract.node_types import node_type_shot, node_type_task, node_type_asset
# From patch operation name to fields that operation may edit.
VALID_SHOT_PATCH_FIELDS = {
u'from-blender': {
u'name',
u'picture',
u'properties.trim_start_in_frames',
u'properties.trim_end_in_frames',
u'properties.duration_in_edit_in_frames',
u'properties.cut_in_timeline_in_frames',
u'properties.status',
u'properties.used_in_edit',
'from-blender': {
'name',
'picture',
'properties.trim_start_in_frames',
'properties.trim_end_in_frames',
'properties.duration_in_edit_in_frames',
'properties.cut_in_timeline_in_frames',
'properties.status',
'properties.used_in_edit',
},
u'from-web': {
u'properties.status',
u'properties.notes',
u'description',
'from-web': {
'properties.status',
'properties.notes',
'description',
},
}
VALID_SHOT_PATCH_OPERATIONS = {
u'from-blender', u'from-web', u'unlink', u'relink',
'from-blender', 'from-web', 'unlink', 'relink',
}
log = logging.getLogger(__name__)
@@ -101,7 +101,7 @@ class ShotAssetManager(object):
node_props = dict(
name='New %s' % typename,
project=project_id,
user=flask_login.current_user.objectid,
user=current_user.objectid,
node_type=node_type['name'],
properties={
'status': node_type['dyn_schema']['status']['default'],
@@ -110,7 +110,7 @@ class ShotAssetManager(object):
node = pillarsdk.Node(node_props)
node.create(api=api)
return node
return pillarsdk.Node.find(node._id, api=api)
def create_shot(self, project):
"""Creates a new shot, owned by the current user.
@@ -282,15 +282,15 @@ def patch_shot(node_id, patch):
node_setattr(node, key, value)
else:
# Remaining operations are for marking as 'in use' or 'not in use'.
if node.get('_deleted', False) and op == u'unlink':
if node.get('_deleted', False) and op == 'unlink':
# We won't undelete a node in response to an unlink request.
return pillar.api.utils.jsonify({'_deleted': True,
'_etag': node['_etag'],
'_id': node['_id']})
used_in_edit = {
u'unlink': False,
u'relink': True,
'unlink': False,
'relink': True,
}[op]
node['properties']['used_in_edit'] = used_in_edit
@@ -308,8 +308,8 @@ def assert_is_valid_patch(patch):
raise wz_exceptions.BadRequest("PATCH should have a key 'op' indicating the operation.")
if op not in VALID_SHOT_PATCH_OPERATIONS:
valid_ops = u', '.join(sorted(VALID_SHOT_PATCH_OPERATIONS))
raise wz_exceptions.BadRequest(u'Operation should be one of %s' % valid_ops)
valid_ops = ', '.join(sorted(VALID_SHOT_PATCH_OPERATIONS))
raise wz_exceptions.BadRequest('Operation should be one of %s' % valid_ops)
if op not in VALID_SHOT_PATCH_FIELDS:
# Valid operation, and we don't have to check the fields.
@@ -324,7 +324,7 @@ def assert_is_valid_patch(patch):
disallowed_fields = fields - allowed_fields
if disallowed_fields:
raise wz_exceptions.BadRequest(u"Operation '%s' does not allow you to set fields %s" % (
raise wz_exceptions.BadRequest("Operation '%s' does not allow you to set fields %s" % (
op, disallowed_fields
))

View File

@@ -10,7 +10,7 @@ import logging
from attract.node_types.shot import node_type_shot, human_readable_properties
from attract.node_types.asset import node_type_asset
from pillar.api.nodes import only_for_node_type_decorator
from pillar.api.nodes.eve_hooks import only_for_node_type_decorator
import pillar.api.activities
import pillar.api.utils.authentication
import pillar.api.utils
@@ -72,8 +72,8 @@ def activity_after_replacing_shot_asset(shot_or_asset, original):
descr = 'changed the thumbnail of %s "%s"' % (typename, shot_or_asset['name'])
elif key == 'properties.status':
val_shot = pillar.web.jinja.format_undertitle(val_shot)
elif isinstance(val_shot, basestring) and len(val_shot) > 80:
val_shot = val_shot[:80] + u''
elif isinstance(val_shot, str) and len(val_shot) > 80:
val_shot = val_shot[:80] + ''
if descr is None:
# A name change activity contains both the old and the new name.

View File

@@ -1,18 +1,17 @@
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 import current_app
from pillar.web.projects.routes import project_navigation_links
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 attract.node_types.asset import node_type_asset
from attract import current_attract
from . import routes_common
@@ -25,19 +24,20 @@ 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, 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)
can_use_attract = current_attract.auth.current_user_may(current_attract.auth.Actions.USE)
navigation_links = project_navigation_links(project, pillar_api())
extension_sidebar_links = current_app.extension_sidebar_links(project)
selected_id = asset_id or task_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,
selected_id=selected_id,
project=project,
attract_props=attract_props)
can_use_attract=can_use_attract,
can_create_task=can_use_attract,
can_create_asset=can_use_attract,
navigation_links=navigation_links,
extension_sidebar_links=extension_sidebar_links,
)
@perproject_blueprint.route('/<asset_id>')
@@ -48,12 +48,17 @@ def view_asset(project, attract_props, asset_id):
asset, node_type = routes_common.view_node(project, asset_id, node_type_asset['name'])
auth = current_attract.auth
can_use_attract = auth.current_user_may(auth.Actions.USE)
can_edit = can_use_attract and 'PUT' in asset.allowed_methods
return render_template('attract/assets/view_asset_embed.html',
asset=asset,
project=project,
asset_node_type=node_type,
attract_props=attract_props,
can_edit='PUT' in asset.allowed_methods)
can_use_attract=can_use_attract,
can_edit=can_edit)
@perproject_blueprint.route('/<asset_id>', methods=['POST'])
@@ -81,7 +86,7 @@ def create_asset(project):
project_url=project['url'],
asset_id=asset['_id'])
resp.status_code = 201
return flask.make_response(flask.jsonify({'asset_id': asset['_id']}), 201)
return flask.make_response(flask.jsonify(asset.to_dict()), 201)
@perproject_blueprint.route('/<asset_id>/activities')

View File

@@ -1,14 +1,14 @@
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 pillar.auth import current_user
from attract import current_attract, ROLES_REQUIRED_TO_VIEW_ITEMS
from attract import current_attract
log = logging.getLogger(__name__)
@@ -53,12 +53,10 @@ def for_project(node_type_name, task_types_for_nt, project, attract_props,
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):
if not current_user.has_cap('attract-view'):
raise wz_exceptions.Forbidden()
api = pillar_api()

View File

@@ -1,18 +1,18 @@
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 import current_app
from pillar.web.projects.routes import project_navigation_links
from pillar.web.system_util import pillar_api
from attract.routes import attract_project_view
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 attract.node_types.shot import node_type_shot
from attract import current_attract
from . import routes_common
@@ -25,9 +25,11 @@ 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):
node_type_name = node_type_shot['name']
shots, tasks_for_shots, task_types_for_template = routes_common.for_project(
node_type_shot['name'],
task_types,
node_type_name,
attract_props['task_types'][node_type_name],
project, attract_props, task_id, shot_id)
# Some aggregated stats
@@ -37,16 +39,24 @@ def for_project(project, attract_props, task_id=None, shot_id=None):
for shot in shots
if shot.properties.used_in_edit),
}
can_use_attract = current_attract.auth.current_user_may(current_attract.auth.Actions.USE)
navigation_links = project_navigation_links(project, pillar_api())
extension_sidebar_links = current_app.extension_sidebar_links(project)
selected_id = shot_id or task_id
return render_template('attract/shots/for_project.html',
shots=shots,
tasks_for_shots=tasks_for_shots,
task_types=task_types_for_template,
open_task_id=task_id,
open_shot_id=shot_id,
selected_id=selected_id,
project=project,
attract_props=attract_props,
stats=stats)
stats=stats,
can_use_attract=can_use_attract,
can_create_task=can_use_attract,
navigation_links=navigation_links,
extension_sidebar_links=extension_sidebar_links)
@perproject_blueprint.route('/<shot_id>')
@@ -56,13 +66,15 @@ def view_shot(project, attract_props, shot_id):
return for_project(project, attract_props, shot_id=shot_id)
shot, node_type = routes_common.view_node(project, shot_id, node_type_shot['name'])
can_use_attract = current_attract.auth.current_user_may(current_attract.auth.Actions.USE)
return render_template('attract/shots/view_shot_embed.html',
shot=shot,
project=project,
shot_node_type=node_type,
attract_props=attract_props,
can_edit='PUT' in shot.allowed_methods)
can_use_attract=can_use_attract,
can_edit=can_use_attract and 'PUT' in shot.allowed_methods)
@perproject_blueprint.route('/<shot_id>', methods=['POST'])
@@ -71,6 +83,9 @@ def save(project, shot_id):
log.info('Saving shot %s', shot_id)
log.debug('Form data: %s', request.form)
if not current_attract.auth.current_user_may(current_attract.auth.Actions.USE):
raise wz_exceptions.Forbidden()
shot_dict = request.form.to_dict()
current_attract.shot_manager.edit_shot(shot_id, **shot_dict)
@@ -84,6 +99,9 @@ def save(project, shot_id):
@perproject_blueprint.route('/create', methods=['POST', 'GET'])
@attract_project_view()
def create_shot(project):
if not current_attract.auth.current_user_may(current_attract.auth.Actions.USE):
raise wz_exceptions.Forbidden()
shot = current_attract.shot_manager.create_shot(project)
resp = flask.make_response()

View File

@@ -0,0 +1,2 @@
!function(e,n){"function"==typeof define&&define.amd?define(["jquery"],e):"object"==typeof module&&"object"==typeof module.exports?module.exports=e(require("jquery")):e(jQuery)}(function(e,n){function t(n,t){return n&&">"===n.trim()[0]?(n=n.trim().replace(/^>\s*/,""),t.find(n)):n?e(n):t}e.fn.resizable||(e.fn.resizable=function(n){var o={handleSelector:null,resizeWidth:!0,resizeHeight:!0,resizeWidthFrom:"right",resizeHeightFrom:"bottom",onDragStart:null,onDragEnd:null,onDrag:null,touchActionNone:!0};return"object"==typeof n&&(o=e.extend(o,n)),this.each(function(){function n(e){e.stopPropagation(),e.preventDefault()}function i(t){t.preventDefault&&t.preventDefault(),s=c(t),s.width=parseInt(d.width(),10),s.height=parseInt(d.height(),10),a=d.css("transition"),d.css("transition","none"),o.onDragStart&&o.onDragStart(t,d,o)===!1||(o.dragFunc=r,e(document).bind("mousemove.rsz",o.dragFunc),e(document).bind("mouseup.rsz",u),(window.Touch||navigator.maxTouchPoints)&&(e(document).bind("touchmove.rsz",o.dragFunc),e(document).bind("touchend.rsz",u)),e(document).bind("selectstart.rsz",n))}function r(e){var n,t,i=c(e);n="left"===o.resizeWidthFrom?s.width-i.x+s.x:s.width+i.x-s.x,t="top"===o.resizeHeightFrom?s.height-i.y+s.y:s.height+i.y-s.y,o.onDrag&&o.onDrag(e,d,n,t,o)===!1||(o.resizeHeight&&d.height(t),o.resizeWidth&&d.width(n))}function u(t){return t.stopPropagation(),t.preventDefault(),e(document).unbind("mousemove.rsz",o.dragFunc),e(document).unbind("mouseup.rsz",u),(window.Touch||navigator.maxTouchPoints)&&(e(document).unbind("touchmove.rsz",o.dragFunc),e(document).unbind("touchend.rsz",u)),e(document).unbind("selectstart.rsz",n),d.css("transition",a),o.onDragEnd&&o.onDragEnd(t,d,o),!1}function c(e){var n={x:0,y:0,width:0,height:0};if("number"==typeof e.clientX)n.x=e.clientX,n.y=e.clientY;else{if(!e.originalEvent.touches)return null;n.x=e.originalEvent.touches[0].clientX,n.y=e.originalEvent.touches[0].clientY}return n}var s,a,d=e(this),h=t(o.handleSelector,d);o.touchActionNone&&h.css("touch-action","none"),d.addClass("resizable"),h.bind("mousedown.rsz touchstart.rsz",i)})})});
//# sourceMappingURL=jquery-resizable.min.js.map

View File

@@ -1,7 +1,5 @@
"""Subversion interface."""
from __future__ import absolute_import
import collections
import dateutil.parser
import re
@@ -9,7 +7,7 @@ import re
import attr
import blinker
import svn.remote
import svn.common
import svn.exception
from pillar import attrs_extra
task_logged = blinker.NamedSignal('task_logged')
@@ -19,10 +17,11 @@ signals = {
'T': task_logged,
}
# Copy of namedtuple defined in svn.common.log_default().
# Copy of namedtuple defined in svn.common.log_default(),
# extended with our project_id.
LogEntry = collections.namedtuple(
'LogEntry',
['date', 'msg', 'revision', 'author', 'changelist']
['date', 'msg', 'revision', 'author', 'changelist', 'project_id']
)
@@ -56,6 +55,7 @@ class CommitLogObserver(object):
def fetch_and_observe(self):
"""Obtains task IDs from SVN logs."""
# FIXME: this code is unaware of the fact that task markers are only unique per project.
self._log.debug('%s: fetch_and_observe()', self)
try:
@@ -72,7 +72,7 @@ class CommitLogObserver(object):
self.process_log(log_entry)
except svn.common.SvnException:
except svn.exception.SvnException:
# The SVN library just raises a SvnException when something goes wrong,
# without any structured indication of the error. There isn't much else
# we can do, except to log the error and return.

View File

@@ -36,7 +36,7 @@ def subversion_kick(project, attract_props):
@api_blueprint.route('/<project_url>/subversion/log', methods=['POST'])
@authorization.require_login(require_roles={u'service', u'svner'}, require_all=True)
@authorization.require_login(require_roles={'service', 'svner'}, require_all=True)
def subversion_log(project_url):
if request.mimetype != 'application/json':
log.warning('Received %s instead of application/json', request.mimetype)
@@ -87,12 +87,13 @@ def subversion_log(project_url):
except KeyError:
return 'Not set up for Attract', 400
svn_server_url = attract_props['svn_url']
svn_server_url = attract_props.get('svn_url', '-unknown-')
log.debug('Receiving commit from SVN server %s', svn_server_url)
log_entry = subversion.create_log_entry(revision=revision,
msg=commit_message,
author=commit_author,
date_text=commit_date)
date_text=commit_date,
project_id=project['_id'])
observer = subversion.CommitLogObserver()
log.debug('Processing %s via %s', log_entry, observer)
observer.process_log(log_entry)

View File

@@ -4,6 +4,7 @@ import attr
import flask
import flask_login
from dateutil import parser
import bson
import pillarsdk
from pillar import attrs_extra
@@ -48,7 +49,7 @@ class TaskManager(object):
task = pillarsdk.Node(node_props)
task.create(api=api)
return task
return pillarsdk.Node.find(task._id, api=api)
def edit_task(self, task_id, **fields):
"""Edits a task.
@@ -66,7 +67,7 @@ class TaskManager(object):
task.description = fields.pop('description')
task.properties.status = fields.pop('status')
task.properties.task_type = fields.pop('task_type', None)
if isinstance(task.properties.task_type, basestring):
if isinstance(task.properties.task_type, str):
task.properties.task_type = task.properties.task_type.strip() or None
due_date = fields.pop('due_date', None)
@@ -128,7 +129,7 @@ class TaskManager(object):
}}, api=api)
return tasks
def api_task_for_shortcode(self, shortcode):
def api_task_for_shortcode(self, project_id: bson.ObjectId, shortcode: str) -> dict:
"""Returns the task for the given shortcode.
:returns: the task Node, or None if not found.
@@ -137,6 +138,7 @@ class TaskManager(object):
db = flask.current_app.db()
task = db['nodes'].find_one({
'properties.shortcode': shortcode,
'project': project_id,
'node_type': node_type_task['name'],
})
@@ -150,21 +152,22 @@ class TaskManager(object):
:type log_entry: attract.subversion.LogEntry
"""
self._log.info(u"Task '%s' logged in SVN by %s: %s...",
shortcode, log_entry.author, log_entry.msg[:30].replace('\n', ' // '))
self._log.info("Project %s, task '%s' logged in SVN by %s: %s...",
log_entry.project_id, shortcode,
log_entry.author, log_entry.msg[:30].replace('\n', ' // '))
# Find the task
task = self.api_task_for_shortcode(shortcode)
task = self.api_task_for_shortcode(log_entry.project_id, shortcode)
if not task:
self._log.warning(u'Task %s not found, ignoring SVN commit.', shortcode)
self._log.warning('Task %s not found, ignoring SVN commit.', shortcode)
return
# Find the author
db = flask.current_app.db()
proj = db['projects'].find_one({'_id': task['project']},
projection={'extension_props.attract': 1})
projection={'extension_props.attract': 1})
if not proj:
self._log.warning(u'Project %s for task %s not found, ignoring SVN commit.',
self._log.warning('Project %s for task %s not found, ignoring SVN commit.',
task['project'], task['_id'])
return
@@ -175,7 +178,7 @@ class TaskManager(object):
if user_id:
msg = 'committed SVN revision %s: %s' % (log_entry.revision, log_entry.msg)
else:
self._log.warning(u'No Pillar user mapped for SVN user %s, using SVNer account.',
self._log.warning('No Pillar user mapped for SVN user %s, using SVNer account.',
log_entry.author)
user_id = authentication.current_user_id()
msg = 'committed SVN revision %s authored by SVN user %s: %s' % (

View File

@@ -5,7 +5,7 @@ import logging
import itertools
from flask import current_app, g
from pillar.api.nodes import only_for_node_type_decorator
from pillar.api.nodes.eve_hooks import only_for_node_type_decorator
import pillar.api.activities
import pillar.api.utils.authentication
import pillar.web.jinja
@@ -112,7 +112,7 @@ def register_task_activity(task, descr):
def get_user_list(user_list):
if not user_list:
return u'-nobody-'
return '-nobody-'
user_coll = current_app.db()['users']
users = user_coll.find(
@@ -123,7 +123,7 @@ def get_user_list(user_list):
)
names = [user['full_name'] for user in users]
return u', '.join(names)
return ', '.join(names)
@only_for_task
@@ -150,8 +150,8 @@ def activity_after_replacing_task(task, original):
human_key = 'assigned users'
val_task = get_user_list(val_task)
descr = 'assigned task "%s" to %s' % (task['name'], val_task)
elif isinstance(val_task, basestring) and len(val_task) > 80:
val_task = val_task[:80] + u''
elif isinstance(val_task, str) and len(val_task) > 80:
val_task = val_task[:80] + ''
if descr is None:
# A name change activity contains both the old and the new name.
@@ -181,13 +181,13 @@ def activity_after_deleting_task(task):
def set_defaults(task):
from attract import shortcodes
shortcode = shortcodes.generate_shortcode(task['project'], task['node_type'], u'T')
shortcode = shortcodes.generate_shortcode(task['project'], task['node_type'], 'T')
task_properties = task.setdefault('properties', {})
task_properties['shortcode'] = shortcode
# When the task is assigned to a user, this prevents a change of 'assigned_to' to a dict.
# Instead, the activity will be registered on 'assigned_to.users', which is nicer.
task_properties.setdefault('assigned_to', {u'users': []})
task_properties.setdefault('assigned_to', {'users': []})
def nodes_set_defaults(nodes):

View File

@@ -1,20 +1,22 @@
import logging
from dateutil import parser
from flask import Blueprint, render_template, request, current_app
from flask import Blueprint, render_template, request, current_app, session
import flask
import flask_login
import werkzeug.exceptions as wz_exceptions
import pillarsdk
from pillar.web.projects.routes import project_navigation_links
from pillar.web.system_util import pillar_api
import pillar.api.utils
import pillar.web.subquery
from pillar.auth import current_user
from attract.routes import attract_project_view
from attract.node_types.task import node_type_task
from attract.node_types.shot import node_type_shot
from attract import current_attract, ROLES_REQUIRED_TO_VIEW_ITEMS, EXTENSION_NAME
from attract import current_attract, EXTENSION_NAME
blueprint = Blueprint('attract.tasks', __name__, url_prefix='/tasks')
perproject_blueprint = Blueprint('attract.tasks.perproject', __name__,
@@ -28,13 +30,16 @@ def index():
if not user.is_authenticated:
return render_template('attract/tasks/index.html')
project = session.get('attract_last_project')
tasks = current_attract.task_manager.tasks_for_user(user.objectid)
return render_template('attract/tasks/for_user.html',
tasks=tasks['_items'],
project=project,
task_count=tasks['_meta']['total'])
@blueprint.route('/<task_id>', methods=['DELETE'])
@flask_login.login_required
def delete(task_id):
log.info('Deleting task %s', task_id)
@@ -45,13 +50,21 @@ def delete(task_id):
@perproject_blueprint.route('/', endpoint='index')
@attract_project_view()
@attract_project_view(extension_props=False)
def for_project(project, task_id=None):
tasks = current_attract.task_manager.tasks_for_project(project['_id'])
can_use_attract = current_attract.auth.current_user_may(current_attract.auth.Actions.USE)
navigation_links = project_navigation_links(project, pillar_api())
extension_sidebar_links = current_app.extension_sidebar_links(project)
return render_template('attract/tasks/for_project.html',
tasks=tasks['_items'],
open_task_id=task_id,
project=project)
selected_id=task_id,
project=project,
can_use_attract=can_use_attract,
can_create_task=can_use_attract,
navigation_links=navigation_links,
extension_sidebar_links=extension_sidebar_links)
@perproject_blueprint.route('/<task_id>')
@@ -61,7 +74,7 @@ def view_task(project, attract_props, task_id):
return for_project(project, task_id=task_id)
# Task list is public, task details are not.
if not flask_login.current_user.has_role(*ROLES_REQUIRED_TO_VIEW_ITEMS):
if not current_user.has_cap('attract-view'):
raise wz_exceptions.Forbidden()
api = pillar_api()
@@ -76,7 +89,10 @@ def view_task(project, attract_props, task_id):
task.properties.due_date = parser.parse('%s' % task.properties.due_date)
# Fetch project users so that we can assign them tasks
can_edit = 'PUT' in task.allowed_methods
auth = current_attract.auth
can_use_attract = auth.current_user_may(auth.Actions.USE)
can_edit = 'PUT' in task.allowed_methods and can_use_attract
if can_edit:
users = project.get_users(api=api)
project.users = users['_items']
@@ -96,6 +112,7 @@ def view_task(project, attract_props, task_id):
task_types=task_types,
attract_props=attract_props.to_dict(),
attract_context=request.args.get('context'),
can_use_attract=can_use_attract,
can_edit=can_edit)
@@ -166,7 +183,7 @@ def create_task(project):
task_id=task['_id'])
resp.status_code = 201
return flask.make_response(flask.jsonify({'task_id': task['_id']}), 201)
return flask.make_response(flask.jsonify(task.to_dict()), 201)
@perproject_blueprint.route('/<task_id>/activities')

View File

@@ -1,8 +0,0 @@
#!/bin/bash
echo
echo "==========================================================================="
echo "Dummy deploy script for people with a 'git pp' alias to push to production."
echo "Run deploy script on your server project."
echo "When done, press [ENTER] to stop this script."
read dummy

6
deploy_docs.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
cd docs
command -v mkdocs 2>/dev/null 2>&1 || { echo >&2 "Command mkdocs not found. Are you in the right venv?"; exit 1; }
mkdocs build
rsync -auv ./site/* armadillica@attract.studio:/home/armadillica/attract.studio/docs

View File

@@ -0,0 +1,12 @@
# Roadmap
The day-to-day planning for development is available on
[developer.blender.org](https://developer.blender.org/project/board/72/). In this section we summarize
the high level goals for the projects.
## Self-provisionable Server
Make it possible for developers to run the full stack in a local environment. In similar way to
Flamenco, the challenge is to get the Server (and its Pillar core) disconnected from Blender Cloud.
## Data filtering and sorting
Provide basic filtering and sorting functionality for assets, tasks, and assets. For example, make it
possible to find all tasks assigned to a specific user, or in a specific set of statuses.

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="512"
height="512"
id="svg2"
version="1.1"
inkscape:version="0.48.4 r9939"
sodipodi:docname="logo_attract.svg"
inkscape:export-filename="/shared/software/attract3/logo/attract_logo_bw_1080.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.98994949"
inkscape:cx="222.50239"
inkscape:cy="247.16912"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
fit-margin-top="10"
fit-margin-left="10"
fit-margin-right="10"
fit-margin-bottom="10"
inkscape:showpageshadow="false"
inkscape:window-width="1418"
inkscape:window-height="855"
inkscape:window-x="424"
inkscape:window-y="590"
inkscape:window-maximized="0" />
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-81.656231,-11.7557)">
<g
id="g2995"
transform="matrix(0.66925035,0,0,0.66925035,371.3471,-72.927844)"
style="fill:white;fill-opacity:1;stroke:none;fill-rule:nonzero">
<path
style="fill:white;fill-opacity:1;stroke:none;fill-rule:nonzero"
d="m 166.06342,857.36804 c -2.0241,-1.63901 -2.11292,-2.89269 -2.98911,-42.18649 -1.56775,-70.30787 -3.12503,-148.81192 -3.94749,-198.99728 -0.74743,-45.6073 -0.92521,-48.94732 -2.9808,-56 -8.92469,-30.62035 -34.68288,-56.26535 -93.79792,-93.38574 -71.4521403,-44.86728 -217.86949,-119.37839 -308.62723,-157.05897 -11.53416,-4.78873 -14.07545,-5.52908 -15.12259,-4.40564 -0.95964,1.02956 -1.65295,20.23647 -2.91842,80.85035 -0.91287,43.725 -2.11692,81.49904 -2.67566,83.94232 -4.76367,20.83065 -32.50574,59.96134 -61.53114,86.79078 -22.87237,21.14193 -43.26077,35.03351 -52.17813,35.55142 l -3.71013,0.21548 -0.59028,-5.5 c -0.7529,-7.01532 6.55125,-231.1601 8.09781,-248.5 1.54288,-17.29859 4.40736,-33.86113 7.10175,-41.06252 2.60264,-6.9562 13.58837,-23.48911 31.94188,-48.07076 16.29143,-21.81981 31.85794,-37.75226 58.44884,-59.82286 30.66053,-25.44841 34.3329,-27.45527 50.34292,-27.51121 12.67088,-0.0443 25.74008,2.76304 48.65708,10.45172 79.57398,26.69719 271.10729,122.71606 363.96686,182.4627 53.1755,34.21358 78.51413,59.37766 87.39716,86.79513 2.37747,7.33804 5.6679,26.42936 7.11089,41.2578 1.13125,11.62495 8.05544,216.85126 8.19865,243 0.0845,15.42373 -0.0729,16.89815 -2.41221,22.60399 -11.11776,27.11712 -47.59735,68.13655 -82.76135,93.06091 -11.85316,8.40156 -21.59148,13.91534 -27.71765,15.69357 -4.78422,1.38871 -5.39093,1.37419 -7.30373,-0.1747 z M -53.75396,623.06914 c -16.11333,-1.99066 -30.39323,-10.66862 -38.76393,-23.55704 -5.77741,-8.89551 -8.7432,-19.27201 -8.81115,-30.82783 -0.0945,-16.06423 4.45371,-26.8127 16.30168,-38.52498 12.04616,-11.9082 24.23682,-16.62772 40.61216,-15.72271 26.10197,1.44258 45.6435897,18.04702 51.0008797,43.33525 2.75772,13.01736 1.11109,24.63774 -5.19616,36.66971 -10.56115,20.14689 -32.3266697,31.44641 -55.1434797,28.6276 z"
id="path2999"
inkscape:connector-curvature="0" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

17
docs/docs/index.md Normal file
View File

@@ -0,0 +1,17 @@
# Attract Docs
Welcome to the Attract documentation pages! Here we collect both user and development docs. Attract
is the production tracking and management software used at Blender Animation Studio.
## Main features
* Shot list, asset list and task list
* Extensible design, supporting custom task types with custom attributes
* Integration of SVN activity in a task activity list
* Completely Free and Open Source software
## Status of the documentation
Documentation is an ongoing effort. We are currently focusing on user documentation, aimed at
Blender Cloud subscribers. If you are interested in installing Attract on your own infrastructure,
consider checking out the sources and README.md files.

View File

@@ -0,0 +1,10 @@
# Installation & Configuration
!!! note
This section of the manual is work in progress.
The following video shows how to set up a Blender Cloud project with Attract, which is currently
the only way officially supported. A step-by-step text version will follow.
<iframe width="750" height="350" src="https://www.youtube.com/embed/FoUua_Jlmpc?rel=0" frameborder="0"
gesture="media" allow="encrypted-media" allowfullscreen></iframe>

View File

@@ -0,0 +1,6 @@
# Introduction
!!! note
This section of the manual is work in progress.
This manual aims at describing the features of Attract and providing examples on how to use them.

View File

@@ -0,0 +1,28 @@
# Subversion Integration
By hooking your Subversion server to Attract, tasks can be automatically updated based on tags in
the commit message. This requires a post-commit hook to be installed on the Subversion server.
Example hook:
REPOS="$1"
REV="$2"
TXN_NAME="$3"
/usr/bin/python3 "$REPOS"/hooks/notify_attract.py "$REPOS" "$REV"
The
[`notify_attract.py`](https://developer.blender.org/source/attract/browse/master/notify_attract.py)
file is bundled with Attract's source code, and needs to be copied to the Subversion repository's
`hook` directory. After copying, modify the code to include an authentication token and the mapping from the Subversion repository name to the project URL. For example:
AUTH_TOKEN = 'SRVxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
PILLAR_URL = 'https://cloud.blender.org/'
PROJECT_URLS = { # Mapping from SVN repository name to Attract project URL.
'svnreponame': 'p-123456789',
}
The authentication token must be created on the server using `manage.py attract create_svner_account {emailaddress} {project-url}`
The project should have an `svn_url` extension property in the MongoDB database, which points to
the Subversion server URL. This is for logging use only.

42
docs/mkdocs.yml Normal file
View File

@@ -0,0 +1,42 @@
# Project information
site_name: 'Attract'
site_description: 'Free and Open Source production tracking for film makers'
site_author: 'Blender Institute'
site_url: 'https://attract.studio/'
# Repository
repo_name: 'Developed on blender.org'
repo_url: 'https://developer.blender.org/project/view/72/'
# Copyright
copyright: 'Copyright &copy; 2016 Blender Institute - CC-BY-SA v4.0.'
theme:
name: 'material'
logo: 'img/logo_attract_white.svg'
palette:
primary: 'blue grey'
accent: 'deep orange'
social:
- type: 'github'
link: 'https://github.com/armadillica'
- type: 'twitter'
link: 'https://twitter.com/Blender_Cloud'
pages:
- Home: 'index.md'
- User Manual:
- 'user_manual/introduction.md'
- 'user_manual/installation.md'
- 'user_manual/subversion.md'
- Developer Docs:
- 'developer_docs/roadmap.md'
# Google Analytics
google_analytics:
- 'UA-13043630-10'
- 'auto'
markdown_extensions:
- admonition

View File

@@ -1,29 +1,43 @@
var argv = require('minimist')(process.argv.slice(2));
var autoprefixer = require('gulp-autoprefixer');
var chmod = require('gulp-chmod');
var concat = require('gulp-concat');
var gulp = require('gulp');
var gulpif = require('gulp-if');
var jade = require('gulp-jade');
var livereload = require('gulp-livereload');
var plumber = require('gulp-plumber');
var rename = require('gulp-rename');
var sass = require('gulp-sass');
var sourcemaps = require('gulp-sourcemaps');
var uglify = require('gulp-uglify');
var cache = require('gulp-cached');
let argv = require('minimist')(process.argv.slice(2));
let autoprefixer = require('gulp-autoprefixer');
let cache = require('gulp-cached');
let chmod = require('gulp-chmod');
let concat = require('gulp-concat');
let git = require('gulp-git');
let gulp = require('gulp');
let gulpif = require('gulp-if');
let plumber = require('gulp-plumber');
let pug = require('gulp-pug');
let rename = require('gulp-rename');
let sass = require('gulp-sass');
let sourcemaps = require('gulp-sourcemaps');
let uglify = require('gulp-uglify-es').default;
let browserify = require('browserify');
let babelify = require('babelify');
let sourceStream = require('vinyl-source-stream');
let glob = require('glob');
let es = require('event-stream');
let path = require('path');
let buffer = require('vinyl-buffer');
var enabled = {
uglify: argv.production,
maps: argv.production,
let enabled = {
chmod: argv.production,
cleanup: argv.production,
failCheck: argv.production,
maps: argv.production,
prettyPug: !argv.production,
liveReload: !argv.production
uglify: argv.production,
};
let destination = {
css: 'attract/static/assets/css',
pug: 'attract/templates',
js: 'attract/static/assets/js/generated',
}
/* CSS */
gulp.task('styles', function() {
gulp.task('styles', function(done) {
gulp.src('src/styles/**/*.sass')
.pipe(gulpif(enabled.failCheck, plumber()))
.pipe(gulpif(enabled.maps, sourcemaps.init()))
@@ -32,26 +46,26 @@ gulp.task('styles', function() {
))
.pipe(autoprefixer("last 3 versions"))
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
.pipe(gulp.dest('attract/static/assets/css'))
.pipe(gulpif(enabled.liveReload, livereload()));
.pipe(gulp.dest(destination.css));
done();
});
/* Templates - Jade */
gulp.task('templates', function() {
gulp.src('src/templates/**/*.jade')
/* Templates - Pug */
gulp.task('templates', function(done) {
gulp.src('src/templates/**/*.pug')
.pipe(gulpif(enabled.failCheck, plumber()))
.pipe(cache('templating'))
.pipe(jade({
.pipe(pug({
pretty: enabled.prettyPug
}))
.pipe(gulp.dest('attract/templates/'))
.pipe(gulpif(enabled.liveReload, livereload()));
.pipe(gulp.dest(destination.pug));
done();
});
/* Individual Uglified Scripts */
gulp.task('scripts', function() {
gulp.task('scripts', function(done) {
gulp.src('src/scripts/*.js')
.pipe(gulpif(enabled.failCheck, plumber()))
.pipe(cache('scripting'))
@@ -59,40 +73,92 @@ gulp.task('scripts', function() {
.pipe(gulpif(enabled.uglify, uglify()))
.pipe(rename({suffix: '.min'}))
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
.pipe(chmod(644))
.pipe(gulp.dest('attract/static/assets/js/generated/'))
.pipe(gulpif(enabled.liveReload, livereload()));
.pipe(gulpif(enabled.chmod, chmod(0o644)))
.pipe(gulp.dest(destination.js));
done();
});
function browserify_base(entry) {
let pathSplited = path.dirname(entry).split(path.sep);
let moduleName = pathSplited[pathSplited.length - 1];
return browserify({
entries: [entry],
standalone: 'attract.' + moduleName,
})
.transform(babelify, { "presets": ["@babel/preset-env"] })
.bundle()
.pipe(gulpif(enabled.failCheck, plumber()))
.pipe(sourceStream(path.basename(entry)))
.pipe(buffer())
.pipe(rename({
basename: moduleName,
extname: '.min.js'
}));
}
function browserify_common() {
return glob.sync('src/scripts/js/es6/common/**/init.js').map(browserify_base);
}
gulp.task('scripts_browserify', function(done) {
glob('src/scripts/js/es6/individual/**/init.js', function(err, files) {
if(err) done(err);
let tasks = files.map(function(entry) {
return browserify_base(entry)
.pipe(gulpif(enabled.maps, sourcemaps.init()))
.pipe(gulpif(enabled.uglify, uglify()))
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
.pipe(gulp.dest(destination.js));
});
es.merge(tasks).on('end', done);
})
});
/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js */
/* Since it's always loaded, it's only for functions that we want site-wide */
gulp.task('scripts_tutti', function() {
gulp.src('src/scripts/tutti/**/*.js')
gulp.task('scripts_tutti', function(done) {
let toUglify = ['src/scripts/tutti/**/*.js']
es.merge(gulp.src(toUglify), ...browserify_common())
.pipe(gulpif(enabled.failCheck, plumber()))
.pipe(gulpif(enabled.maps, sourcemaps.init()))
.pipe(concat("tutti.min.js"))
.pipe(gulpif(enabled.uglify, uglify()))
.pipe(gulpif(enabled.maps, sourcemaps.write(".")))
.pipe(chmod(644))
.pipe(gulp.dest('attract/static/assets/js/generated/'))
.pipe(gulpif(enabled.liveReload, livereload()));
.pipe(gulpif(enabled.chmod, chmod(0o644)))
.pipe(gulp.dest(destination.js));
done();
});
// While developing, run 'gulp watch'
gulp.task('watch',function() {
// Only listen for live reloads if ran with --livereload
if (argv.livereload){
livereload.listen();
gulp.task('watch',function(done) {
gulp.watch('src/styles/**/*.sass', gulp.series('styles'));
gulp.watch('src/templates/**/*.pug', gulp.series('templates'));
gulp.watch('src/scripts/*.js', gulp.series('scripts'));
gulp.watch('src/scripts/tutti/*.js', gulp.series('scripts_tutti'));
gulp.watch('src/scripts/js/**/*.js', gulp.series('scripts_browserify', 'scripts_tutti'));
done();
});
// Erases all generated files in output directories.
gulp.task('cleanup', function(done) {
let paths = [];
for (attr in destination) {
paths.push(destination[attr]);
}
gulp.watch('src/styles/**/*.sass',['styles']);
gulp.watch('src/templates/**/*.jade',['templates']);
gulp.watch('src/scripts/*.js',['scripts']);
gulp.watch('src/scripts/tutti/*.js',['scripts_tutti']);
git.clean({ args: '-f -X ' + paths.join(' ') }, function (err) {
if(err) throw err;
});
done();
});
// Run 'gulp' to build everything at once
gulp.task('default', ['styles', 'templates', 'scripts', 'scripts_tutti']);
let tasks = [];
if (enabled.cleanup) tasks.push('cleanup');
gulp.task('default', gulp.parallel(tasks.concat(['styles', 'templates', 'scripts', 'scripts_tutti'])));

7
manage.py Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env python
from pillar import cli
from runserver import app
cli.manager.app = app
cli.manager.run()

8780
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,40 @@
{
"name": "attract",
"license": "GPL",
"license": "GPL-2.0+",
"author": "Blender Institute",
"repository": {
"type": "git",
"url": "git://git.blender.org/attract-server.git"
"url": "git://git.blender.org/attract.git"
},
"devDependencies": {
"gulp": "~3.9.1",
"gulp-autoprefixer": "~2.3.1",
"gulp-cached": "~1.1.0",
"gulp-chmod": "~1.3.0",
"gulp-concat": "~2.6.0",
"gulp-if": "^2.0.1",
"gulp-jade": "~1.1.0",
"gulp-livereload": "~3.8.1",
"gulp-plumber": "~1.1.0",
"gulp-rename": "~1.2.2",
"gulp-sass": "~2.3.1",
"gulp-sourcemaps": "~1.6.0",
"gulp-uglify": "~1.5.3",
"minimist": "^1.2.0"
"@babel/core": "7.1.6",
"@babel/preset-env": "7.1.6",
"acorn": "5.7.3",
"babel-core": "7.0.0-bridge.0",
"babelify": "10.0.0",
"browserify": "16.2.3",
"gulp": "^4.0",
"gulp-autoprefixer": "^6.0.0",
"gulp-cached": "^1.1.1",
"gulp-chmod": "^2.0.0",
"gulp-concat": "^2.6.1",
"gulp-if": "^2.0.2",
"gulp-git": "^2.8.0",
"gulp-plumber": "^1.2.0",
"gulp-pug": "^4.0.1",
"gulp-rename": "^1.4.0",
"gulp-sass": "^4.1.0",
"gulp-sourcemaps": "^2.6.4",
"gulp-uglify-es": "^1.0.4",
"minimist": "^1.2.0",
"vinyl-buffer": "1.0.1",
"vinyl-source-stream": "2.0.0"
},
"dependencies": {
"bootstrap": "^4.3.1",
"event-stream": "^4.0.1",
"jquery": "^3.4.1",
"natives": "^1.1.6",
"popper.js": "^1.14.4"
}
}

25
pyproject.toml Normal file
View File

@@ -0,0 +1,25 @@
[tool.poetry]
name = "attract"
version = "1.1dev0"
description = ""
authors = [
"Francesco Siddi <francesco@blender.org>",
"Pablo Vazquez <pablo@blender.studio>",
"Sybren Stüvel <sybren@blender.studio>",
]
include = ["readme.md", "LICENSE.txt"]
[tool.poetry.dependencies]
python = "~3.6"
cryptography = "2.7"
pillar = {path = "../pillar"}
svn = "~0.3"
[tool.poetry.dev-dependencies]
pillar-devdeps = {path = "../pillar/devdeps"}
mkdocs = "~1.0"
mkdocs-material = "~4.2"
[build-system]
requires = ["poetry==1.0","cryptography==2.7","setuptools==51.0.0","wheel==0.35.1"]
build-backend = "poetry.masonry.api"

View File

@@ -2,3 +2,14 @@
This project contains Attract, a task management extension for the Pillar
platform.
## Development
Dependencies are managed via [Poetry](https://poetry.eustace.io/).
```
git clone git@git.blender.org:pillar-python-sdk.git ../pillar-python-sdk
git clone git@git.blender.org:pillar.git ../pillar
pip install -U --user poetry
poetry install
```

View File

@@ -1,13 +0,0 @@
# Primary requirements:
# pillarsdk
# pillar
attrs==16.2.0
svn==0.3.43
python-dateutil==2.5.3
# Testing requirements:
pytest==3.0.1
responses==0.5.1
pytest-cov==2.3.1
mock==2.0.0

View File

@@ -1,44 +0,0 @@
#!/usr/bin/env bash
set -e # error out when one of the commands in the script errors.
# macOS does not support readlink -f, so we use greadlink instead
if [[ `uname` == 'Darwin' ]]; then
command -v greadlink 2>/dev/null 2>&1 || { echo >&2 "Install greadlink using brew."; exit 1; }
readlink='greadlink'
else
readlink='readlink'
fi
ATTRACT_DIR="$(dirname "$($readlink -f "$0")")"
if [ ! -d "$ATTRACT_DIR" ]; then
echo "Unable to find Attract dir '$ATTRACT_DIR'"
exit 1
fi
ASSETS="$ATTRACT_DIR/attract/static/assets/"
TEMPLATES="$ATTRACT_DIR/attract/templates/attract"
if [ ! -d "$ASSETS" ]; then
echo "Unable to find assets dir $ASSETS"
exit 1
fi
cd $ATTRACT_DIR
if [ $(git rev-parse --abbrev-ref HEAD) != "production" ]; then
echo "You are NOT on the production branch, refusing to rsync_ui." >&2
exit 1
fi
echo
echo "*** GULPA GULPA ***"
./gulp --production
echo
echo "*** SYNCING ASSETS ***"
# Exclude files managed by Git.
rsync -avh $ASSETS --exclude js/vendor/ root@cloud.blender.org:/data/git/attract/attract/static/assets/
echo
echo "*** SYNCING TEMPLATES ***"
rsync -avh $TEMPLATES root@cloud.blender.org:/data/git/attract/attract/templates/

View File

@@ -1,5 +1,5 @@
[tool:pytest]
addopts = -v --cov attract --cov-report term-missing --ignore node_modules -x
addopts = -v --cov attract --cov-report term-missing --ignore node_modules
[pep8]
max-line-length = 100

View File

@@ -1,21 +0,0 @@
#!/usr/bin/env python
"""Setup file for the Attract extension."""
import setuptools
setuptools.setup(
name='attract',
version='1.0',
packages=setuptools.find_packages('.', exclude=['test']),
install_requires=[
'pillar>=2.0',
],
tests_require=[
'pytest>=2.9.1',
'responses>=0.5.1',
'pytest-cov>=2.2.1',
'mock>=2.0.0',
],
zip_safe=False,
)

View File

@@ -0,0 +1,2 @@
Gulp will transpile everything in this folder. Every sub folder containing a init.js file exporting functions/classes
will be packed into a module in tutti.js under the namespace attract.FOLDER_NAME.

View File

@@ -0,0 +1,9 @@
function thenGetProjectAssets(projectId) {
let where = {
project: projectId,
node_type: 'attract_asset'
}
return pillar.api.thenGetNodes(where);
}
export { thenGetProjectAssets }

View File

@@ -0,0 +1,3 @@
export {thenGetProjectAssets} from './assets'
export {thenGetProjectShots} from './shots'
export {thenGetTasks, thenGetProjectTasks} from './tasks'

View File

@@ -0,0 +1,10 @@
function thenGetProjectShots(projectId) {
let where = {
project: projectId,
node_type: 'attract_shot'
};
let sort = '-properties.used_in_edit,properties.cut_in_timeline_in_frames';
return pillar.api.thenGetNodes(where, {}, sort);
}
export { thenGetProjectShots }

View File

@@ -0,0 +1,21 @@
function thenGetTasks(parentId) {
let where = {
parent: parentId,
node_type: 'attract_task'
};
return pillar.api.thenGetNodes(where);
}
function thenGetProjectTasks(projectId) {
let where = {
project: projectId,
node_type: 'attract_task'
}
let embedded = {
parent: 1
}
let sort = 'parent';
return pillar.api.thenGetNodes(where, embedded, sort);
}
export { thenGetTasks, thenGetProjectTasks }

View File

@@ -0,0 +1,52 @@
class ProjectAuth {
constructor() {
this.canCreateTask = false;
this.canCreateAsset = false;
this.canUseAttract = false;
}
}
class Auth {
constructor() {
this.perProjectAuth = {}
}
canUserCreateTask(projectId) {
let projectAuth = this.getProjectAuth(projectId);
return projectAuth.canCreateTask;
}
canUserCreateAsset(projectId) {
let projectAuth = this.getProjectAuth(projectId);
return projectAuth.canCreateAsset;
}
canUserCanUseAttract(projectId) {
let projectAuth = this.getProjectAuth(projectId);
return projectAuth.canUseAttract;
}
setUserCanUseAttract(projectId, canUseAttract) {
let projectAuth = this.getProjectAuth(projectId);
projectAuth.canUseAttract = canUseAttract;
}
setUserCanCreateTask(projectId, canCreateTask) {
let projectAuth = this.getProjectAuth(projectId);
projectAuth.canCreateTask = canCreateTask;
}
setUserCanCreateAsset(projectId, canCreateAsset) {
let projectAuth = this.getProjectAuth(projectId);
projectAuth.canCreateAsset = canCreateAsset;
}
getProjectAuth(projectId) {
this.perProjectAuth[projectId] = this.perProjectAuth[projectId] || new ProjectAuth();
return this.perProjectAuth[projectId];
}
}
let AttractAuth = new Auth();
export {AttractAuth}

View File

@@ -0,0 +1 @@
export { AttractAuth } from './auth'

View File

@@ -0,0 +1,211 @@
import { AssetsTable } from './assetstable/Table'
import { TasksTable } from './taskstable/Table'
import { ShotsTable } from './shotstable/Table'
import './detailedview/Viewer'
const BrowserHistoryState = pillar.vuecomponents.mixins.BrowserHistoryState;
const StateSaveMode = pillar.vuecomponents.mixins.StateSaveMode;
const TEMPLATE =`
<div class="attract-app">
<div id="col_main">
<component
:is="tableComponentName"
:project="project"
:selectedIds="currentSelectedIds"
:canChangeSelectionCB="canChangeSelectionCB"
:componentState="initialTableState"
@selected-items-changed="onSelectItemsChanged"
@is-initialized="onTableInitialized"
@component-state-changed="onTableStateChanged"
/>
</div>
<div class="col-splitter"/>
<attract-detailed-view id="col_right"
:items="selectedItems"
:project="project"
:contextType="contextType"
@objects-are-edited="onEditingObjects"
/>
</div>
`;
class ComponentState {
/**
* Serializable state of this component.
*
* @param {Object} tableState
*/
constructor(tableState) {
this.tableState = tableState;
}
}
/**
* Component wrapping a table for selecting attract_task/asset/shot nodes, and a editor to edit the selected node(s).
* Selected row filters and visible columns are stored in localStorage per project/context. This makes the settings
* sticky between sessions in the same browser.
* Selected nodes are stored in window.history. This makes it possible to move back/forward in browser and the selection
* will change accordingly.
*/
Vue.component('attract-app', {
template: TEMPLATE,
mixins: [BrowserHistoryState],
props: {
projectId: String,
selectedIds: {
type: Array,
default: () => {return []}
},
contextType: {
type: String,
default: 'shots',
}
},
data() {
return {
currentSelectedIds: this.selectedIds,
selectedItems: [],
isEditing: false,
isTableInited: false,
project: null
}
},
created() {
pillar.api.thenGetProject(this.projectId)
.then((project) =>{
this.project = project;
});
},
computed: {
selectedNames() {
return this.selectedItems.map(it => it.name);
},
tableComponentName() {
if(!this.project) return '';
switch (this.contextType) {
case 'assets': return AssetsTable.options.name;
case 'tasks': return TasksTable.options.name;
case 'shots': return ShotsTable.options.name;
default:
console.log('Unknown context type', this.contextType);
return ShotsTable.$options.name;
}
},
/**
* @override BrowserHistoryState
*/
browserHistoryState() {
if(this.isTableInited) {
return {
'selectedIds': this.currentSelectedIds
};
} else {
return {};
}
},
/**
* @override BrowserHistoryState
*/
historyStateUrl() {
let projectUrl = ProjectUtils.projectUrl();
if(this.selectedItems.length !== 1) {
return `/attract/${projectUrl}/${this.contextType}/`;
} else {
let selected = this.selectedItems[0];
let node_type = selected.node_type;
if (node_type === 'attract_task' && this.contextType !== 'tasks') {
return `/attract/${projectUrl}/${this.contextType}/with-task/${selected._id}`;
} else {
return `/attract/${projectUrl}/${this.contextType}/${selected._id}`;
}
}
},
stateStorageKey() {
return `attract.${this.projectId}.${this.contextType}`;
},
initialAppState() {
let stateJsonStr;
try {
stateJsonStr = localStorage.getItem(this.stateStorageKey);
} catch (error) {
// Log and ignore.
console.warn('Unable to restore state:', error);
}
return stateJsonStr ? JSON.parse(stateJsonStr) : undefined;
},
initialTableState() {
return this.initialAppState ? this.initialAppState.tableState : undefined;
}
},
watch: {
selectedItems(newValue) {
function equals(arrA, arrB) {
if (arrA.length === arrB.length) {
return arrA.every(it => arrB.includes(it)) &&
arrB.every(it => arrA.includes(it))
}
return false;
}
let newSelectedIds = newValue.map(item => item._id);
// They will be equal for instance when we pop browser history
if (equals(newSelectedIds, this.currentSelectedIds)) return;
this.currentSelectedIds = newSelectedIds;
}
},
methods: {
onSelectItemsChanged(selectedItems) {
this.selectedItems = selectedItems;
},
onEditingObjects(isEditing) {
this.isEditing = !!isEditing;
},
onTableInitialized() {
this.isTableInited = true;
},
/**
* Save table state to localStorage per project and context
* @param {Object} newState
*/
onTableStateChanged(newState) {
let appState = new ComponentState(newState);
let stateJsonStr = JSON.stringify(appState);
try {
localStorage.setItem(this.stateStorageKey, stateJsonStr);
} catch (error) {
// Log and ignore.
console.warn('Unable to save state:', error);
}
},
canChangeSelectionCB() {
if(this.isEditing) {
let retval = confirm("You have unsaved data. Do you want to discard it?");
return retval;
}
return true
},
/**
* @override BrowserHistoryState
*/
stateSaveMode(newState, oldState) {
if (!this.isTableInited) {
return StateSaveMode.IGNORE;
}
if (!oldState) {
// Initial state. Replace what we have so we can go back to this state
return StateSaveMode.REPLACE;
}
if (newState.selectedIds.length > 1 && oldState.selectedIds.length > 1) {
// To not spam history when multiselecting items
return StateSaveMode.REPLACE;
}
return StateSaveMode.PUSH;
},
/**
* @override BrowserHistoryState
*/
applyHistoryState(newState) {
this.currentSelectedIds = newState.selectedIds || this.currentSelectedIds;
}
},
});

View File

@@ -0,0 +1,51 @@
import './Activity'
const TEMPLATE =`
<div class="d-activity">
<ul>
<attract-activity
v-for="a in activities"
:key="a._id"
:activity="a"
/>
</ul>
</div>
`;
Vue.component('attract-activities', {
template: TEMPLATE,
props: {
objectId: String,
outdated: {
type: Boolean,
default: true
}
},
data() {
return {
activities: [],
}
},
watch: {
objectId() {
this.fetchActivities();
},
outdated(isOutDated) {
if(isOutDated) {
this.fetchActivities();
}
}
},
created() {
this.fetchActivities()
},
methods: {
fetchActivities() {
pillar.api.thenGetNodeActivities(this.objectId)
.then(it => {
this.activities = it['_items'];
this.$emit('activities-updated');
});
}
},
});

View File

@@ -0,0 +1,29 @@
const TEMPLATE =`
<li>
<img class="actor-avatar"
:src="activity.actor_user.avatar"
/>
<span class="date"
:title="activity._created">
{{ prettyCreated }}
</span>
<span class="actor">
{{ activity.actor_user.full_name }}
</span>
<span class="verb">
{{ activity.verb }}
</span>
</li>
`;
Vue.component('attract-activity', {
template: TEMPLATE,
props: {
activity: Object,
},
computed: {
prettyCreated() {
return pillar.utils.prettyDate(this.activity._created, true);
}
},
});

View File

@@ -0,0 +1,64 @@
let PillarTable = pillar.vuecomponents.table.PillarTable;
import {AssetColumnFactory} from './columns/AssetColumnFactory'
import {AssetRowsSource} from './rows/AssetRowsSource'
import {RowFilter} from '../attracttable/rows/filter/RowFilter'
const TEMPLATE =`
<div class="pillar-table-actions">
<button class="action"
v-if="canAddAsset"
@click="createNewAsset"
>
<i class="pi-plus">New Asset</i>
</button>
</div>
`;
let TableActions = {
template: TEMPLATE,
computed: {
canAddAsset() {
let projectId = ProjectUtils.projectId();
return attract.auth.AttractAuth.canUserCreateAsset(projectId);
}
},
methods: {
createNewAsset(event) {
thenCreateAsset(ProjectUtils.projectUrl())
.then((asset) => {
this.$emit('item-clicked', event, asset._id);
});
}
},
}
let AssetsTable = Vue.component('attract-assets-table', {
extends: PillarTable,
props: {
project: Object
},
data() {
return {
columnFactory: new AssetColumnFactory(this.project),
rowsSource: new AssetRowsSource(this.project._id),
rowFilterConfig: {validStatuses: this.getValidStatuses()}
}
},
methods: {
getValidStatuses() {
for (const it of this.project.node_types) {
if(it.name === 'attract_asset'){
return it.dyn_schema.status.allowed;
}
}
console.warn('Did not find allowed statuses for node type attract_shot');
return [];
}
},
components: {
'pillar-table-actions': TableActions,
'pillar-table-row-filter': RowFilter,
}
});
export { AssetsTable };

View File

@@ -0,0 +1,30 @@
import { TaskColumn } from '../../attracttable/columns/Tasks';
import { FirstTaskDueDate, NextTaskDueDate, LastTaskDueDate } from '../../attracttable/columns/TaskDueDate';
import { Status } from '../../attracttable/columns/Status';
import { RowObject } from '../../attracttable/columns/RowObject'
let ColumnFactoryBase = pillar.vuecomponents.table.columns.ColumnFactoryBase;
let Created = pillar.vuecomponents.table.columns.Created;
let Updated = pillar.vuecomponents.table.columns.Updated;
class AssetColumnFactory extends ColumnFactoryBase{
constructor(project) {
super();
this.project = project;
}
thenGetColumns() {
let taskTypes = this.project.extension_props.attract.task_types.attract_asset;
let taskColumns = taskTypes.map((tType) => {
return new TaskColumn(tType, 'asset-task');
})
return Promise.resolve(
[new Status(), new RowObject()]
.concat(taskColumns)
.concat([new NextTaskDueDate(), new Created(), new Updated()])
);
}
}
export { AssetColumnFactory }

View File

@@ -0,0 +1,38 @@
import { AttractRowBase } from '../../attracttable/rows/AttractRowBase'
import { TaskEventListener } from '../../attracttable/rows/TaskEventListener';
import { TaskRow } from '../../taskstable/rows/TaskRow'
class AssetRow extends AttractRowBase {
constructor(asset) {
super(asset);
this.tasks = [];
}
_thenInitImpl() {
return attract.api.thenGetTasks(this.getId())
.then((response) => {
this.tasks = response._items.map(it => new TaskRow(it));
this.registerTaskEventListeners();
return Promise.all(
this.tasks.map(t => t.thenInit())
);
});
}
registerTaskEventListeners() {
new TaskEventListener(this).register();
}
getTasksOfType(taskType) {
return this.tasks.filter((t) => {
return t.getProperties().task_type === taskType;
})
}
getChildObjects() {
return this.tasks;
}
}
export { AssetRow }

View File

@@ -0,0 +1,18 @@
import { AttractRowsSourceBase } from '../../attracttable/rows/AttractRowsSourceBase'
import { AssetRow } from './AssetRow'
class AssetRowsSource extends AttractRowsSourceBase {
constructor(projectId) {
super(projectId, 'attract_asset', AssetRow);
}
thenGetRowObjects() {
return attract.api.thenGetProjectAssets(this.projectId)
.then((result) => {
let assets = result._items;
this.initRowObjects(assets);
});
}
}
export { AssetRowsSource }

View File

@@ -0,0 +1,37 @@
let CellDefault = pillar.vuecomponents.table.cells.renderer.CellDefault;
const TEMPLATE =`
<div>
<a
@click="ignoreDefault"
:href="cellLink"
:title="cellValue"
>
{{ cellValue }}
</a>
</div>
`;
let CellRowObject = Vue.component('pillar-cell-row-object', {
extends: CellDefault,
template: TEMPLATE,
computed: {
cellLink() {
let project_url = ProjectUtils.projectUrl();
let item_type = this.itemType();
return `/attract/${project_url}/${item_type}s/${this.rowObject.getId()}`;
}
},
methods: {
itemType() {
let node_type = this.rowObject.underlyingObject.node_type;
return node_type.replace('attract_', ''); // eg. attract_task to tasks
},
ignoreDefault(event) {
// Don't follow link, let the event bubble and the row handles it
event.preventDefault();
}
},
});
export { CellRowObject }

View File

@@ -0,0 +1,12 @@
let CellDefault = pillar.vuecomponents.table.cells.renderer.CellDefault;
let CellStatus = Vue.component('attract-cell-Status', {
extends: CellDefault,
computed: {
cellValue() {
return '';
},
},
});
export { CellStatus }

View File

@@ -0,0 +1,53 @@
let CellDefault = pillar.vuecomponents.table.cells.renderer.CellDefault;
import './CellTasksLink'
const TEMPLATE =`
<div>
<div class="tasks">
<attract-cell-task-link
v-for="t in tasks"
:task="t"
:itemType="itemType"
:key="t._id"
@item-clicked="$emit('item-clicked', ...arguments)"
/>
</div>
<button class="add-task-link"
v-if="canAddTask"
@click.prevent.stop="onAddTask"
>
<i class="pi-plus">Task</i>
</button>
</div>
`;
let CellTasks = Vue.component('attract-cell-tasks', {
extends: CellDefault,
template: TEMPLATE,
computed: {
tasks() {
return this.rawCellValue;
},
canAddTask() {
if (this.tasks.length < 1 ) {
let projectId = ProjectUtils.projectId();
return attract.auth.AttractAuth.canUserCreateTask(projectId);
}
return false;
},
itemType() {
let node_type = this.rowObject.underlyingObject.node_type;
return node_type.replace('attract_', '') + 's'; // eg. attract_asset to assets
}
},
methods: {
onAddTask(event) {
thenCreateTask(this.rowObject.getId(), this.column.taskType)
.then((task) => {
this.$emit('item-clicked', event, task._id);
});
},
},
});
export { CellTasks }

View File

@@ -0,0 +1,33 @@
const TEMPLATE =`
<a class="task"
:class="taskClass"
:href="taskLink"
:title="taskTitle"
@click.prevent.stop="$emit('item-clicked', arguments[0], task.getId())"
/>
`;
let CellTasksLink = Vue.component('attract-cell-task-link', {
template: TEMPLATE,
props: {
task: Object,
itemType: String,
},
computed: {
taskClass() {
let classes = {'active': this.task.isSelected};
classes[`status-${this.task.getProperties().status}`] = true;
return classes;
},
taskLink() {
let project_url = ProjectUtils.projectUrl();
return `/attract/${project_url}/${this.itemType}/with-task/${this.task.getId()}`;
},
taskTitle() {
let status = (this.task.getProperties().status || '').replace('_', ' ');
return `Task: ${this.task.getName()}\nStatus: ${status}`
},
},
});
export { CellTasksLink }

View File

@@ -0,0 +1,19 @@
let ColumnBase = pillar.vuecomponents.table.columns.ColumnBase;
import { CellRowObject } from '../cells/renderer/CellRowObject'
class RowObject extends ColumnBase {
constructor() {
super('Name', 'row-object');
this.isMandatory = true;
}
getCellRenderer(rowObject) {
return CellRowObject.options.name;
}
getRawCellValue(rowObject) {
return rowObject.getName() || '<No Name>';
}
}
export { RowObject }

View File

@@ -0,0 +1,47 @@
import {CellStatus} from '../cells/renderer/CellStatus'
let ColumnBase = pillar.vuecomponents.table.columns.ColumnBase;
export class Status extends ColumnBase {
constructor() {
super('', 'attract-status');
this.isMandatory = true;
}
getCellRenderer(rowObject) {
return CellStatus.options.name;
}
getRawCellValue(rowObject) {
return rowObject.getProperties().status;
}
getCellTitle(rawCellValue, rowObject) {
function capitalize(str) {
if(str.length === 0) return str;
return str.charAt(0).toUpperCase() + str.slice(1);
}
let formatedStatus = capitalize(rawCellValue).replace('_', ' ');
return `Status: ${formatedStatus}`;
}
getCellClasses(rawCellValue, rowObject) {
let classes = super.getCellClasses(rawCellValue, rowObject);
classes[`status-${rawCellValue}`] = true;
return classes;
}
compareRows(rowObject1, rowObject2) {
let sortNbr1 = this.getSortNumber(rowObject1);
let sortNbr2 = this.getSortNumber(rowObject2);
if (sortNbr1 === sortNbr2) return 0;
return sortNbr1 < sortNbr2 ? -1 : 1;
}
getSortNumber(rowObject) {
let statusStr = rowObject.getProperties().status;
switch (statusStr) {
case 'on_hold': return 10;
case 'todo': return 20;
case 'in_progress': return 30;
case 'review': return 40;
case 'cbb': return 50;
case 'approved': return 60;
case 'final': return 70;
default: return 9999; // invalid status
}
}
}

View File

@@ -0,0 +1,81 @@
let DateColumnBase = pillar.vuecomponents.table.columns.DateColumnBase;
function firstDate(prevDate, task) {
let candidate = task.properties.due_date;
if (prevDate && candidate) {
if (prevDate !== candidate) {
return new Date(candidate) < new Date(prevDate) ? candidate : prevDate;
}
}
return prevDate || candidate;
}
function lastDate(prevDate, task) {
let candidate = task.properties.due_date;
if (prevDate && candidate) {
if (prevDate !== candidate) {
return new Date(candidate) > new Date(prevDate) ? candidate : prevDate;
}
}
return prevDate || candidate;
}
function nextDate(prevDate, task) {
let candidate = task.properties.due_date;
if(candidate && new Date(candidate) >= new Date()) {
return firstDate(prevDate, task);
}
return prevDate;
}
class DueDate extends DateColumnBase {
getCellClasses(dueDate, rowObject) {
let classes = super.getCellClasses(dueDate, rowObject);
let isPostDueDate = false;
if (dueDate) {
isPostDueDate = new Date(dueDate) < new Date();
}
classes['warning'] = isPostDueDate;
return classes;
}
}
export class FirstTaskDueDate extends DueDate {
constructor() {
super('First Due Date', 'first-duedate');
}
getRawCellValue(rowObject) {
let tasks = (rowObject.tasks || []).map(task => task.underlyingObject);
return tasks.reduce(firstDate, undefined) || '';
}
}
export class LastTaskDueDate extends DueDate {
constructor() {
super('Last Due Date', 'last-duedate');
}
getRawCellValue(rowObject) {
let tasks = (rowObject.tasks || []).map(task => task.underlyingObject);
return tasks.reduce(lastDate, undefined) || '';
}
}
export class NextTaskDueDate extends DueDate {
constructor() {
super('Next Due Date', 'next-duedate');
}
getRawCellValue(rowObject) {
let tasks = (rowObject.tasks || []).map(task => task.underlyingObject);
return tasks.reduce(nextDate, undefined) || '';
}
}
export class TaskDueDate extends DueDate {
constructor() {
super('Due Date', 'duedate');
}
getRawCellValue(rowObject) {
let task = rowObject.getTask();
return task.properties.due_date || '';
}
}

View File

@@ -0,0 +1,30 @@
let ColumnBase = pillar.vuecomponents.table.columns.ColumnBase;
import { CellTasks } from '../cells/renderer/CellTasks'
export class TaskColumn extends ColumnBase {
constructor(taskType, columnType) {
super(taskType, columnType);
this.taskType = taskType;
}
getCellRenderer(rowObject) {
return CellTasks.options.name;
}
getRawCellValue(rowObject) {
return rowObject.getTasksOfType(this.taskType);
}
compareRows(rowObject1, rowObject2) {
let numTasks1 = this.getRawCellValue(rowObject1).length;
let numTasks2 = this.getRawCellValue(rowObject2).length;
if (numTasks1 === numTasks2) return 0;
return numTasks1 < numTasks2 ? -1 : 1;
}
getColumnClasses() {
let classes = super.getColumnClasses();
classes[this.taskType] = true;
return classes;
}
}

View File

@@ -0,0 +1,18 @@
let RowBase = pillar.vuecomponents.table.rows.RowBase;
class AttractRowBase extends RowBase {
constructor(underlyingObject) {
super(underlyingObject);
pillar.events.Nodes.onUpdated(this.getId(), this.onRowUpdated.bind(this));
}
onRowUpdated(event) {
this.underlyingObject = event.detail;
}
getStatus() {
return this.underlyingObject.properties.status;
}
}
export { AttractRowBase }

View File

@@ -0,0 +1,43 @@
let RowObjectsSourceBase = pillar.vuecomponents.table.rows.RowObjectsSourceBase;
/**
* Base for all attract tables. Listens to events on create/delete events and keeps the the source up to date
* accordingly.
*/
class AttractRowsSourceBase extends RowObjectsSourceBase {
constructor(projectId, node_type, rowClass) {
super();
this.projectId = projectId;
this.node_type = node_type;
this.rowClass = rowClass;
}
createRow(node) {
let row = new this.rowClass(node);
this.registerListeners(row);
return row;
}
initRowObjects(nodes) {
this.rowObjects = nodes.map(this.createRow.bind(this));
pillar.events.Nodes.onCreated(this.node_type, this.onNodeCreated.bind(this));
}
registerListeners(rowObject) {
pillar.events.Nodes.onDeleted(rowObject.getId(), this.onNodeDeleted.bind(this));
}
onNodeDeleted(event) {
this.rowObjects = this.rowObjects.filter((rowObj) => {
return rowObj.getId() !== event.detail;
});
}
onNodeCreated(event) {
let rowObj = this.createRow(event.detail);
rowObj.thenInit();
this.rowObjects = this.rowObjects.concat(rowObj);
}
}
export { AttractRowsSourceBase }

View File

@@ -0,0 +1,44 @@
import { TaskRow } from '../../taskstable/rows/TaskRow'
/**
* Helper class that listens to events triggered when a RowObject task is updated/created/deleted and keep the tasks
* array of a RowObject up to date accordingly.
*/
export class TaskEventListener {
constructor(rowWithTasks) {
this.rowObject = rowWithTasks;
}
register() {
pillar.events.Nodes.onParentCreated(this.rowObject.getId(), 'attract_task', this.onTaskCreated.bind(this));
this.rowObject.tasks.forEach(this.registerEventListeners.bind(this));
}
registerEventListeners(task) {
pillar.events.Nodes.onUpdated(task.getId(), this.onTaskUpdated.bind(this));
pillar.events.Nodes.onDeleted(task.getId(), this.onTaskDeleted.bind(this));
}
onTaskCreated(event) {
let task = new TaskRow(event.detail);
task.thenInit();
this.registerEventListeners(task);
this.rowObject.tasks = this.rowObject.tasks.concat(task);
}
onTaskUpdated(event) {
let updatedTask = event.detail;
for (const task of this.rowObject.tasks) {
if (task.getId() === updatedTask._id) {
task.underlyingObject = updatedTask;
break;
}
}
}
onTaskDeleted(event) {
this.rowObject.tasks = this.rowObject.tasks.filter((t) => {
return t.getId() !== event.detail;
});
}
}

View File

@@ -0,0 +1,67 @@
let NameFilter = pillar.vuecomponents.table.rows.filter.NameFilter;
let StatusFilter = pillar.vuecomponents.table.rows.filter.StatusFilter;
const TEMPLATE =`
<div class="pillar-table-row-filter">
<name-filter
:rowObjects="rowObjects"
:componentState="(componentState || {}).nameFilter"
@visible-row-objects-changed="onNameFiltered"
@component-state-changed="onNameFilterStateChanged"
/>
<status-filter
:availableStatuses="availableStatuses"
:rowObjects="nameFilteredRowObjects"
:componentState="(componentState || {}).statusFilter"
@visible-row-objects-changed="$emit('visible-row-objects-changed', ...arguments)"
@component-state-changed="onStatusFilterStateChanged"
/>
</div>
`;
let RowFilter = {
template: TEMPLATE,
props: {
rowObjects: Array,
componentState: Object,
config: Object
},
data() {
return {
availableStatuses: this.config.validStatuses,
nameFilteredRowObjects: [],
nameFilterState: (this.componentState || {}).nameFilter,
statusFilterState: (this.componentState || {}).statusFilter,
}
},
methods: {
onNameFiltered(visibleRowObjects) {
this.nameFilteredRowObjects = visibleRowObjects;
},
onNameFilterStateChanged(stateObj) {
this.nameFilterState = stateObj;
},
onStatusFilterStateChanged(stateObj) {
this.statusFilterState = stateObj;
}
},
computed: {
currentComponentState() {
return {
nameFilter: this.nameFilterState,
statusFilter: this.statusFilterState,
};
}
},
watch: {
currentComponentState(newValue) {
this.$emit('component-state-changed', newValue);
}
},
components: {
'name-filter': NameFilter,
'status-filter': StatusFilter
}
};
export { RowFilter }

View File

@@ -0,0 +1,12 @@
const TEMPLATE =`
<div class="attract-box item-details-empty">Select Something</div>
`;
/**
* For when nothing is selected in the table
*/
let Empty = Vue.component('attract-editor-empty', {
template: TEMPLATE,
});
export {Empty}

View File

@@ -0,0 +1,14 @@
const TEMPLATE =`
<div class="attract-box multiple-types">
Objects of different types selected
</div>
`;
/**
* For when objects of different node_type is selected.
*/
let MultipleTypes = Vue.component('attract-editor-multiple-types', {
template: TEMPLATE,
});
export {MultipleTypes}

View File

@@ -0,0 +1,108 @@
import { Empty } from './Empty'
import { MultipleTypes } from './MultipleTypes'
import { AssetEditor } from '../editor/AssetEditor'
import { TaskEditor } from '../editor/TaskEditor'
import { ShotEditor } from '../editor/ShotEditor'
import '../activities/Activities'
const TEMPLATE =`
<div class="attract-detailed-view">
<div class="col_header">
<span class="header_text">
{{ headerText }}
<i
v-if="isMultiItemsView"
class="pi-link"
title="Multiple items selected"
/>
</span>
</div>
<component
:is="editorType"
:items="items"
:project="project"
:contextType="contextType"
@objects-are-edited="$emit('objects-are-edited', ...arguments)"
@saved-items="activitiesIsOutdated"
/>
<attract-activities
v-if="isSingleItemView"
:objectId="singleObjectId"
:outdated="isActivitiesOutdated"
@activities-updated="activitiesIsUpToDate"
/>
<comments-tree
v-if="isSingleItemView"
:parentId="singleObjectId"
@new-comment="activitiesIsOutdated"
/>
</div>
`;
Vue.component('attract-detailed-view', {
template: TEMPLATE,
props: {
items: Array,
project: Object,
contextType: String
},
data() {
return {
isActivitiesOutdated: true
}
},
computed: {
headerText() {
switch(this.items.length) {
case 0: return 'Details';
case 1: return `${this.itemsTypeFormated} Details`
default: return `${this.itemsTypeFormated} Details (${this.items.length})`;
}
},
itemsType() {
let itemsType = this.items.reduce((prevType, it) => {
if(prevType) {
return prevType === it.node_type ? prevType : 'multiple_types';
}
return it.node_type;
}, null);
return itemsType || 'empty';
},
itemsTypeFormated() {
return this.itemsType.replace('attract_', '').replace('multiple_types', '');
},
editorType() {
if(!this.project) {
return Empty.options.name;
}
switch(this.itemsType) {
case 'attract_asset': return AssetEditor.options.name;
case 'attract_shot': return ShotEditor.options.name;
case 'attract_task': return TaskEditor.options.name;
case 'multiple_types': return MultipleTypes.options.name;
case 'empty': return Empty.options.name;
default:
console.log('No editor for:', this.itemsType);
return Empty.options.name;
}
},
isMultiItemsView() {
return this.items.length > 1;
},
isSingleItemView() {
return this.items.length === 1;
},
singleObjectId() {
return this.isSingleItemView ? this.items[0]._id : '';
},
},
methods: {
activitiesIsOutdated() {
this.isActivitiesOutdated = true;
},
activitiesIsUpToDate() {
this.isActivitiesOutdated = false
}
},
});

View File

@@ -0,0 +1,114 @@
import './base/TextArea'
import './base/ConclusiveMark'
import {EditorBase, BaseProps} from './base/EditorBase'
const TEMPLATE =`
<div class="attract-box asset with-status"
:class="editorClasses"
>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="nameProp"
/>
<input class="item-name" name="name" type="text" placeholder="Asset Name"
:disabled="!canEdit"
:class="classesForProperty(nameProp)"
v-model="nameProp.value"/>
<button class="copy-to-clipboard btn item-id" name="Copy to Clipboard" type="button" title="Copy ID to clipboard"
v-if="!isMultpleItems"
:data-clipboard-text="items[0]._id"
>
ID
</button>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="descriptionProp"
/>
<attract-editor-text-area
placeholder="Description"
:disabled="!canEdit"
:class="classesForProperty(descriptionProp)"
v-model="descriptionProp.value"
/>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="statusProp"
/>
<label for="item-status">Status:</label>
<select class="input-transparent" id="item-status" name="status"
:class="classesForProperty(statusProp)"
:disabled="!canEdit"
v-model="statusProp.value"
>
<option value=undefined disabled="true" v-if="!statusProp.isConclusive()"> *** </option>
<option v-for="it in allowedStatusesPair"
:key="it.id"
:value="it.id"
>{{it.text}}</option>
</select>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="notesProp"
/>
<attract-editor-text-area
placeholder="Notes"
:class="classesForProperty(notesProp)"
:disabled="!canEdit"
v-model="notesProp.value"
/>
</div>
<div class="input-group-separator"/>
<div class="input-group"
v-if="canEdit"
>
<button class="btn btn-outline-success btn-block" id="item-save" type="submit"
@click="save"
:disabled="isSaving"
>
<i class="pi-check"/>Save Asset
</button>
</div>
</div>
`;
const AllProps = Object.freeze({
...BaseProps,
PROP_NOTES: 'properties.notes',
});
let ALL_PROPERTIES = [];
for (const key in AllProps) {
ALL_PROPERTIES.push(AllProps[key]);
}
let AssetEditor = Vue.component('attract-editor-asset', {
template: TEMPLATE,
extends: EditorBase,
data() {
return {
multiEditEngine: this.createEditorEngine(ALL_PROPERTIES),
}
},
watch: {
items() {
this.multiEditEngine = this.createEditorEngine(ALL_PROPERTIES);
},
},
computed: {
notesProp() {
return this.multiEditEngine.getProperty(AllProps.PROP_NOTES);
},
},
});
export {AssetEditor}

View File

@@ -0,0 +1,247 @@
import './base/TextArea'
import './base/ConclusiveMark'
import {EditorBase, BaseProps} from './base/EditorBase'
const TEMPLATE =`
<div>
<div class="attract-box shot with-status"
:class="editorClasses"
>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="nameProp"
/>
<span title="Shot names can only be updated from Blender." class="item-name">
{{ valueOrNA(nameProp.value) }}
</span>
<button class="copy-to-clipboard btn item-id" name="Copy to Clipboard" type="button" title="Copy ID to clipboard"
v-if="!isMultpleItems"
:data-clipboard-text="items[0]._id"
>
ID
</button>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="descriptionProp"
/>
<attract-editor-text-area
placeholder="Description"
:disabled="!canEdit"
:class="classesForProperty(descriptionProp)"
v-model="descriptionProp.value"
/>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="statusProp"
/>
<label for="item-status">
Status:
</label>
<select id="item-status" name="status" class="input-transparent"
:class="classesForProperty(statusProp)"
:disabled="!canEdit"
v-model="statusProp.value"
>
<option value=undefined disabled="true" v-if="!statusProp.isConclusive()"> *** </option>
<option v-for="it in allowedStatusesPair"
:key="it.id"
:value="it.id"
>{{it.text}}</option>
</select>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="notesProp"
/>
<attract-editor-text-area
placeholder="Notes"
:class="classesForProperty(notesProp)"
:disabled="!canEdit"
v-model="notesProp.value"
/>
</div>
<div class="input-group-separator"/>
<div class="input-group"
v-if="canEdit"
>
<button class="btn btn-outline-success btn-block" id="item-save" type="submit"
@click="save"
:disabled="isSaving"
>
<i class="pi-check"/>Save Shot
</button>
</div>
</div>
<div class="attract-box">
<div class="table item-properties">
<div class="table-body">
<div class="table-row">
<div class="table-cell">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="updatedProp"
/>
Last Update
</div>
<div :title="updatedProp.value" class="table-cell">
<span role="button" data-toggle="collapse" data-target="#task-time-creation" aria-expanded="false" aria-controls="#task-time-creation">
{{ prettyDate(updatedProp.value) }}
</span>
<div id="task-time-creation" class="collapse">
{{ prettyDate(createdProp.value) }}
</div>
</div>
</div>
<div class="table-row">
<div class="table-cell">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="usedInEditProp"
/>
Used in Edit
</div>
<div title="Whether this shot is used in the edit." class="table-cell text-capitalize">
{{ formatBool(usedInEditProp.value) }}
</div>
</div>
<div class="table-row">
<div class="table-cell">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="cutInTimelineProp"
/>
Cut-in
</div>
<div title="Frame number of the first visible frame of this shot." class="table-cell">
at frame {{ valueOrNA(cutInTimelineProp.value) }}
</div>
</div>
<div class="table-row">
<div class="table-cell">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="trimStartProp"
/>
Trim Start
</div>
<div title="How many frames were trimmed off the start of the shot in the edit." class="table-cell">
{{ valueOrNA(trimStartProp.value) }} frames
</div>
</div>
<div class="table-row">
<div class="table-cell">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="trimEndProp"
/>
Trim End
</div>
<div title="How many frames were trimmed off the end of the shot in the edit." class="table-cell">
{{ valueOrNA(trimEndProp.value) }} frames
</div>
</div>
<div class="table-row">
<div class="table-cell">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="durationInEditProp"
/>
Duration in Edit
</div>
<div title="Duration of the visible part of this shot." class="table-cell">
{{ valueOrNA(durationInEditProp.value) }} frames
</div>
</div>
</div>
</div>
</div>
</div>
`;
const AllProps = Object.freeze({
...BaseProps,
UPDATED: '_updated',
CREATED: '_created',
NOTES: 'properties.notes',
USED_IN_EDIT: 'properties.used_in_edit',
CUT_IN_TIMELINE: 'properties.cut_in_timeline_in_frames',
TRIM_START: 'properties.trim_start_in_frames',
TRIM_END: 'properties.trim_end_in_frames',
DURATION_IN_EDIT: 'properties.duration_in_edit_in_frames',
});
let ALL_PROPERTIES = [];
for (const key in AllProps) {
ALL_PROPERTIES.push(AllProps[key]);
}
let ShotEditor = Vue.component('attract-editor-shot', {
template: TEMPLATE,
extends: EditorBase,
data() {
return {
multiEditEngine: this.createEditorEngine(ALL_PROPERTIES),
}
},
watch: {
items() {
this.multiEditEngine = this.createEditorEngine(ALL_PROPERTIES);
},
},
computed: {
notesProp() {
return this.multiEditEngine.getProperty(AllProps.NOTES);
},
updatedProp() {
return this.multiEditEngine.getProperty(AllProps.UPDATED);
},
createdProp() {
return this.multiEditEngine.getProperty(AllProps.CREATED);
},
usedInEditProp() {
return this.multiEditEngine.getProperty(AllProps.USED_IN_EDIT);
},
cutInTimelineProp() {
return this.multiEditEngine.getProperty(AllProps.CUT_IN_TIMELINE);
},
trimStartProp() {
return this.multiEditEngine.getProperty(AllProps.TRIM_START);
},
trimEndProp() {
return this.multiEditEngine.getProperty(AllProps.TRIM_END);
},
durationInEditProp() {
return this.multiEditEngine.getProperty(AllProps.DURATION_IN_EDIT);
},
},
methods: {
valueOrNA(value) {
return value ? value : 'N/A'
},
formatBool(value) {
switch (value) {
case true: return 'Yes';
case false: return 'No';
default: return 'N/A';
}
},
prettyDate(value) {
if(value) {
return pillar.utils.prettyDate(value, true);
}
return 'N/A';
}
},
});
export {ShotEditor}

View File

@@ -0,0 +1,245 @@
import './base/TextArea'
import './base/ConclusiveMark'
import './base/Select2'
import './base/DatePicker'
import {EditorBase, BaseProps} from './base/EditorBase'
const TEMPLATE =`
<div class="attract-box task with-status"
:class="editorClasses"
>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="nameProp"
/>
<input class="item-name" name="name" type="text" placeholder="Task Title"
:disabled="!canEdit"
:class="classesForProperty(nameProp)"
v-model="nameProp.value"/>
<div class="dropdown" style="margin-left: auto"
v-if="canEdit"
>
<button class="btn btn-outline-success dropdown-toggle" id="item-dropdown" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
<i class="pi-more-vertical"/>
</button>
<ul class="dropdown-menu" aria-labelledby="item-dropdown">
<li class="copy-to-clipboard"
v-if="!isMultpleItems"
:data-clipboard-text="items[0]._id">
<a href="javascript:void(0)">
<i class="pi-clipboard-copy"/>
Copy ID to Clipboard
</a>
</li>
<li class="copy-to-clipboard"
v-if="!isMultpleItems"
:data-clipboard-text="'[' + items[0].properties.shortcode + ']'">
<a href="javascript:void(0)">
<i class="pi-clipboard-copy"/>
Copy Shortcode for SVN Commits to Clipboard
</a>
</li>
<li class="divider" role="separator"/>
<li class="item-delete">
<a href="javascript:void(0)"
@click="deleteTasks"
>
<i class="pi-trash"/>
{{ isMultpleItems ? "Delete Tasks" : "Delete Task" }}
</a>
</li>
</ul>
</div>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="descriptionProp"
/>
<attract-editor-text-area
placeholder="Description"
:disabled="!canEdit"
:class="classesForProperty(descriptionProp)"
v-model="descriptionProp.value"
/>
</div>
<div class="input-group-flex">
<div class="input-group field-type"
v-if="(canChangeTaskType)"
>
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="taskTypeProp"
/>
<label id="task-task_type">
Type:
</label>
<select name="task_type" aria-describedby="task-task_type"
:class="classesForProperty(taskTypeProp)"
:disabled="!canEdit"
v-model="taskTypeProp.value"
>
<option value=undefined disabled="true" v-if="!statusProp.isConclusive()"> *** </option>
<option v-for="it in allowedTaskTypesPair"
:key="it.id"
:value="it.id"
>{{it.text}}</option>
</select></div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="statusProp"
/>
<label for="item-status">Status:</label>
<select class="input-transparent" id="item-status" name="status"
:class="classesForProperty(statusProp)"
:disabled="!canEdit"
v-model="statusProp.value"
>
<option value=undefined disabled="true" v-if="!statusProp.isConclusive()"> *** </option>
<option v-for="it in allowedStatusesPair"
:key="it.id"
:value="it.id"
>{{it.text}}</option>
</select>
</div>
</div>
<div class="input-group-separator"/>
<div class="input-group select_multiple">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="assignedToProp"
/>
<label>
Assignees:
</label>
<attract-select2
:class="classesForProperty(assignedToProp)"
:options="users"
:disabled="!canEdit"
v-model="assignedToProp.value">
<option value=undefined disabled="true" v-if="!assignedToProp.isConclusive()"> *** </option>
</attract-select2>
</div>
<div class="input-group">
<attract-property-conslusive-mark
v-if="isMultpleItems"
:prop="dueDateProp"
/>
<label>
Due Date:
</label>
<attract-date-picker id="item-due_date" name="due_date" placeholder="Deadline for Task"
:class="classesForProperty(dueDateProp)"
:disabled="!canEdit"
v-model="dueDateProp.value"
/>
</div>
<div class="input-group-separator"/>
<div class="input-group"
v-if="canEdit"
>
<button class="btn btn-outline-success btn-block" id="item-save" type="submit"
@click="save"
:disabled="isSaving"
>
<i class="pi-check"/>
Save Task
</button>
</div>
</div>
`;
const AllProps = Object.freeze({
...BaseProps,
PARENT: 'parent',
TASK_TYPE: 'properties.task_type',
DUE_DATE: 'properties.due_date',
ASSIGNED_TO: 'properties.assigned_to.users',
SHORT_CODE: 'properties.short_code',
});
let ALL_PROPERTIES = [];
for (const key in AllProps) {
ALL_PROPERTIES.push(AllProps[key]);
}
let TaskEditor = Vue.component('attract-editor-task', {
template: TEMPLATE,
extends: EditorBase,
props: {
contextType: String
},
data() {
return {
multiEditEngine: this.createEditorEngine(ALL_PROPERTIES),
users: [],
}
},
created() {
this.fetchUsers()
},
watch: {
items() {
this.multiEditEngine = this.createEditorEngine(ALL_PROPERTIES);
},
},
computed: {
parentProp() {
return this.multiEditEngine.getProperty(AllProps.PARENT);
},
taskTypeProp() {
return this.multiEditEngine.getProperty(AllProps.TASK_TYPE);
},
allowedTaskTypes() {
let shot_task_types = this.project.extension_props.attract.task_types.attract_shot;
return ['generic', ...shot_task_types];
},
canChangeTaskType() {
return this.parentProp.isConclusive() && !this.parentProp.value;
},
allowedTaskTypesPair() {
function format(status) {
// hair_sim => Hair sim
let first = status[0].toUpperCase();
let last = status.substr(1).replace('_', ' ');
return `${first}${last}`;
}
return this.allowedTaskTypes.map(it => {
return {
id: it,
text: format(it)
}
});
},
dueDateProp() {
return this.multiEditEngine.getProperty(AllProps.DUE_DATE);
},
assignedToProp() {
return this.multiEditEngine.getProperty(AllProps.ASSIGNED_TO);
},
},
methods: {
fetchUsers() {
pillar.api.thenGetProjectUsers(this.project._id)
.then(users => {
this.users = users._items.map(it =>{
return {
id: it._id,
text: it.full_name,
};
});
});
},
deleteTasks() {
this.items.map(pillar.api.thenDeleteNode);
}
},
});
export {TaskEditor}

View File

@@ -0,0 +1,45 @@
const TEMPLATE =`
<i
:class="classes"
:title="toolTip"
/>
`;
/**
* Draws a chain icon. If property is inconclusive it becomes a broken chain.
*/
Vue.component('attract-property-conslusive-mark', {
template: TEMPLATE,
props: {
prop: Object, // MultiProperty
},
computed: {
classes() {
return this.prop.isConclusive() ? 'pi-link' : 'pi-unlink';
},
toolTip() {
if (this.prop.isConclusive()) {
return 'All objects has the same value'
} else {
let values = this.prop.getOriginalValues();
let toolTip = 'Objects has diverging values:';
let i = 0;
for (const it of values) {
if (i === 5) {
toolTip += `\n...`;
break;
}
toolTip += `\n${++i}: ${this.shorten(it)}`;
}
return toolTip;
}
},
},
methods: {
shorten(value) {
let s = `${value}`;
return s.length < 30 ? s : `${s.substr(0, 27)}...`
}
},
});

View File

@@ -0,0 +1,51 @@
/**
* Wrapper around Pikaday
*/
let TEMPLATE = `
<input ref="datepicker" type="text">
`;
Vue.component('attract-date-picker', {
props: {
value: String
},
template: TEMPLATE,
data() {
return {
picker: null // inited in this.initDatePicker()
}
},
mounted: function () {
this.$nextTick(this.initDatePicker);
},
watch: {
value(newValue, oldValue) {
this.picker.setDate(newValue);
}
},
methods: {
initDatePicker() {
let vm = this;
this.picker = new Pikaday(
{
field: this.$refs.datepicker,
firstDay: 1,
showTime: false,
use24hour: true,
format: 'dddd D, MMMM YYYY',
disableWeekends: true,
timeLabel: 'Time: ',
autoClose: true,
incrementMinuteBy: 15,
yearRange: [new Date().getFullYear(),new Date().getFullYear() + 5],
onSelect: function(date) {
// This is a bit ugly. Can we solve this in a better way?
let dateAsConfigedInEve = this.getMoment().format('ddd, DD MMM YYYY [00:00:00 GMT]')
vm.$emit('input', dateAsConfigedInEve);
}
});
this.picker.setDate(this.value);
}
},
}
);

View File

@@ -0,0 +1,140 @@
import {MultiEditEngine} from './MultiEditEngine'
let UnitOfWorkTracker = pillar.vuecomponents.mixins.UnitOfWorkTracker;
/**
* Properties to be edited and/or read using the multi editor engine.
*/
const BaseProps = Object.freeze({
NAME: 'name',
DESCRIPTION: 'description',
STATUS: 'properties.status',
NODE_TYPE: 'node_type',
});
let ALL_BASE_PROPERTIES = [];
for (const key in BaseProps) {
ALL_BASE_PROPERTIES.push(BaseProps[key]);
}
/**
* The base implementation of node editor.
* Extend to fit your needs.
* @emits objects-are-edited(isEdited) When the user starts editing the objects.
*/
let EditorBase = Vue.component('attract-editor-Base', {
mixins: [UnitOfWorkTracker],
props: {
items: Array, // Array of objects to be edited.
project: Object,
},
data() {
return {
multiEditEngine: this.createEditorEngine(ALL_BASE_PROPERTIES),
isSaving: false,
}
},
watch: {
items() {
this.multiEditEngine = this.createEditorEngine(ALL_BASE_PROPERTIES); // MultiEditEngine
},
statusPropEdited(isEdited) {
if(isEdited && this.items.length === 1) {
// Auto save on status is convenient, but could lead to head ache in multi edit.
this.save();
}
},
isEdited(isEdited) {
this.$emit('objects-are-edited', isEdited);
}
},
computed: {
isMultpleItems() {
return this.items.length > 1;
},
nodeTypeProp() {
return this.multiEditEngine.getProperty(BaseProps.NODE_TYPE);
},
allowedStatuses() {
let tmp = this.project.node_types.filter((it) => it.name === this.nodeTypeProp.value);
if(tmp.length === 1) {
let nodeTypeDefinition = tmp[0];
return nodeTypeDefinition.dyn_schema.status.allowed;
}
console.log('Failed to find allowed statused for node type:', this.nodeTypeProp.value);
return [];
},
allowedStatusesPair() {
function format(status) {
// in_progress => In progress
let first = status[0].toUpperCase();
let last = status.substr(1).replace('_', ' ');
return `${first}${last}`;
}
return this.allowedStatuses.map(it => {
return {
id: it,
text: format(it)
}
});
},
nameProp() {
return this.multiEditEngine.getProperty(BaseProps.NAME);
},
descriptionProp() {
return this.multiEditEngine.getProperty(BaseProps.DESCRIPTION);
},
statusProp() {
return this.multiEditEngine.getProperty(BaseProps.STATUS);
},
statusPropEdited() {
return this.statusProp.isEdited();
},
editorClasses() {
let status = this.statusProp.isConclusive() ? this.statusProp.value : 'inconclusive';
let classes = {}
classes[`status-${status}`] = true;
return classes;
},
isEdited() {
return this.multiEditEngine.isEdited();
},
canEdit() {
let canUseAttract = attract.auth.AttractAuth.canUserCanUseAttract(ProjectUtils.projectId());
return canUseAttract && this.multiEditEngine.allowedToEdit();
}
},
methods: {
/**
* @param {MultiProperty} prop
* @returns {Object} Css classes for property
*/
classesForProperty(prop) {
return {
'inconclusive': !prop.isConclusive(),
'edited': prop.isEdited(),
}
},
createEditorEngine(props) {
return new MultiEditEngine(this.items, ...props);
},
save() {
let toBeSaved = this.multiEditEngine.createUpdatedItems();
let promises = toBeSaved.map(pillar.api.thenUpdateNode);
this.isSaving = true;
this.unitOfWork(
Promise.all(promises)
.then(() => {
this.$emit('saved-items');
})
.catch((err) => {toastr.error(pillar.utils.messageFromError(err), 'Save Failed')})
.finally(() => this.isSaving = false)
);
},
},
});
export {EditorBase, BaseProps}

View File

@@ -0,0 +1,323 @@
/**
* MultiEditEngine
* n-MultiProperty
* 1-PropertyCB
*
* Class to edit multiple objects at the same time.
* It keeps track of what object properties has been edited, which properties that still diffs,
*
* @example
* let myObjects = [{
* 'name': 'Bob',
* 'personal': {
* 'hobby': 'Fishing'
* }
* },{
* 'name': 'Greg',
* 'personal': {
* 'hobby': 'Movies'
* }
* }]
* // Create engine with list of objects to edit, and the properties we want to be able to edit.
* let engine = new MultiEditEngine(myObjects,'name', 'personal.hobby');
*
* engine.getProperty('personal.hobby').isConclusive(); // false since one is 'Fishing' and one 'Movies'
* engine.getProperty('personal.hobby').isEdited(); // false
*
* engine.getProperty('personal.hobby').value = 'Fishing';
* engine.getProperty('personal.hobby').isConclusive(); // true
* engine.getProperty('personal.hobby').isEdited(); // true
* engine.getProperty('personal.hobby').getOriginalValues(); // A set with the original values 'Fishing' and 'Movies'
*
* engine.getProperty('name').isConclusive(); // false since one is 'Bob' and one is 'Greg'
* engine.getProperty('personal.hobby').isEdited(); // false since this property has not been edited
*
* let updatedObjects = engine.createUpdatedItems();
* // updatedObjects is now: [{'name': 'Greg', 'hobby': 'Fishing'}]
* // myObjects is still unchanged.
*/
function areEqual(valA, valB) {
if(Array.isArray(valB) && Array.isArray(valB)) {
if(valA.length === valB.length) {
for (let i = 0; i < valA.length; i++) {
if(!areEqual(valA[i], valB[i])) return false;
}
return true;
}
return false;
} else {
return valA === valB;
}
}
class UniqueValues {
constructor() {
this._values = new Set();
}
get size() {
return this._values.size;
}
/**
*
* @param {*} valueCandidate
*/
addIfUnique(valueCandidate) {
if (Array.isArray(valueCandidate)) {
for (const uniqueValue of this._values) {
if(!Array.isArray(uniqueValue)) continue;
if(areEqual(valueCandidate, uniqueValue)) {
// not a new value. Don't add
return;
}
}
this._values.add(valueCandidate);
} else {
this._values.add(valueCandidate);
}
}
getValueOrInconclusive() {
if (this.size === 1) {
return this._values.values().next().value;
}
return INCONCLUSIVE;
}
getValues() {
return new Set([...this._values]);
}
_areArraysEqual(arrA, arrB) {
if(arrA.size === arrB.size) {
for (let i = 0; i < arrA.length; i++) {
if(arrA[i] !== arrB[i]) return false;
}
return true;
}
return false;
}
}
class PropertyCB {
constructor(propertyPath) {
this.name = propertyPath;
this._propertyPath = propertyPath.split('.');
this._propertyKey = this._propertyPath.pop();
}
/**
* Get the property from the item
* @param {Object} item
* @returns {*} Property value
*/
getValue(item) {
let tmp = item;
for (const key of this._propertyPath) {
tmp = (tmp || {})[key]
}
return (tmp || {})[this._propertyKey];
}
/**
* Assign a new value to the property
* @param {Object} item
* @param {*} newValue
*/
setValue(item, newValue) {
let tmp = item;
for (const key of this._propertyPath) {
tmp[key] = tmp[key] || {};
tmp = tmp[key];
}
tmp[this._propertyKey] = newValue;
}
}
// Dummy object to indicate that a property is unedited.
const NOT_SET = Symbol('Not Set');
const INCONCLUSIVE = Symbol('Inconclusive');
class MultiProperty {
/**
*
* @param {String} propPath Dot separeted path to property
*/
constructor(propPath) {
this.propCB = new PropertyCB(propPath);;
this.originalValues = new UniqueValues();
this.newValue = NOT_SET;
}
get value() {
return this.newValue !== NOT_SET ?
this.newValue :
this._getOriginalValue();
}
set value(newValue) {
if (areEqual(newValue, this._getOriginalValue())) {
this.reset();
} else {
this.newValue = newValue;
}
}
/**
* Returns a Set with all values the different object has for the property.
* @returns {Set}
*/
getOriginalValues() {
return this.originalValues.getValues();
}
/**
* Ture if property has been edited.
* @returns {Boolean}
*/
isEdited() {
return this.newValue !== NOT_SET;
}
/**
* Undo changes to property.
*/
reset() {
this.newValue = NOT_SET;
}
/**
* True if all objects has the same value for this property.
* @returns {Boolean}
*/
isConclusive() {
if (this.newValue !== NOT_SET) {
return true;
}
return this.originalValues.size == 1;
}
_applyNewValue(item) {
if (this.isEdited()) {
this.propCB.setValue(item, this.newValue);
return true;
}
return false;
}
_getOriginalValue() {
let origVal = this.originalValues.getValueOrInconclusive()
return origVal !== INCONCLUSIVE ?
origVal : undefined;
}
_addValueFrom(item) {
this.originalValues.addIfUnique(this.propCB.getValue(item));
}
}
class MultiEditEngine {
/**
* @param {Array<Object>} items An array with the objects to be edited.
* @param {...String} propertyPaths Dot separeted paths to properties. 'name', 'properties.status'
*/
constructor(items, ...propertyPaths) {
this.originalItems = items;
this.properties = this._createMultiproperties(propertyPaths);
}
/**
*
* @param {String} propName
* @returns {MultiProperty}
*/
getProperty(propName) {
return this.properties[propName];
}
/**
* True if all the objects has the same value for all of its monitored properties.
* @returns {Boolean}
*/
isConclusive() {
for (const key in this.properties) {
const prop = this.properties[key];
if (prop.isConclusive()) {
return true;
}
}
return false;
}
/**
* True if at least one property has been edited.
* @returns {Boolean}
*/
isEdited() {
for (const key in this.properties) {
const prop = this.properties[key];
if (prop.isEdited()) {
return true;
}
}
return false;
}
/**
* Returns an array with copies of the objects with there new values.
* Only the updated objects are included in the array.
* @returns {Array<Object>}
*/
createUpdatedItems() {
let updatedItems = [];
for (const it of this.originalItems) {
let itemCopy = JSON.parse(JSON.stringify(it));
let hasChanged = false;
for (const key in this.properties) {
const prop = this.properties[key];
hasChanged |= prop._applyNewValue(itemCopy);
}
if(hasChanged) {
updatedItems.push(itemCopy);
}
}
return updatedItems;
}
/**
* True if all items has 'PUT' in 'allowed_methods'. If object has now 'allowed_methods' we return true
* @returns {Boolean}
*/
allowedToEdit() {
for (const it of this.originalItems) {
if(!it.allowed_methods) continue;
if(!it.allowed_methods.includes('PUT')) {
return false;
}
}
return true;
}
/**
* Undo all edits on all properties.
*/
reset() {
for (const key in this.properties) {
this.properties[key].reset();
}
}
_createMultiproperties(propertyPaths) {
let retval = {}
for (const propPath of propertyPaths) {
let prop = new MultiProperty(propPath);
this.originalItems.forEach(prop._addValueFrom.bind(prop));
retval[propPath] = prop;
}
return retval;
}
}
export { MultiEditEngine }

View File

@@ -0,0 +1,56 @@
/**
* Wrapper around jquery select2. Heavily inspired by: https://vuejs.org/v2/examples/select2.html
*/
let TEMPLATE = `
<div class="input-group attract-select2">
<select multiple="" ref="select2" style="display: none;" id="apa"
:disabled="disabled"
>
<slot/>
</select>
</div>
`;
Vue.component('attract-select2', {
props: {
options: Array,
value: Array,
disabled: {
type: Boolean,
default: false
}
},
template: TEMPLATE,
mounted: function () {
this.$nextTick(this.initSelect2);
},
watch: {
value(value) {
// update value
$(this.$refs.select2)
.val(value)
.trigger('change.select2');
},
options(options) {
// update options
$(this.$refs.select2).empty().select2({ data: options });
}
},
beforeDestroy() {
$(this.$refs.select2).off().select2('destroy');
},
methods: {
initSelect2() {
$(this.$refs.select2)
// init select2
.select2({ data: this.options })
.val(this.value)
.trigger('change.select2')
// emit event on change.
.on('change', () => {
this.$emit('input', $(this.$refs.select2).val());
});
}
},
})

View File

@@ -0,0 +1,34 @@
const TEMPLATE = `
<textarea ref="inputField"
v-bind:value="value"
v-on:input="$emit('input', $event.target.value)"
class="input-transparent"
type="text"
rows="2"/>
`;
/**
* Wrapper around regular textarea to make it grow in length as you type.
*/
Vue.component('attract-editor-text-area', {
template: TEMPLATE,
props: {
value: String,
},
watch:{
value() {
this.$nextTick(this.autoSizeInputField);
}
},
mounted() {
this.$nextTick(this.autoSizeInputField);
},
methods: {
autoSizeInputField() {
let elInputField = this.$refs.inputField;
elInputField.style.cssText = 'height:auto; padding:0';
let newInputHeight = elInputField.scrollHeight + 20;
elInputField.style.cssText = `height:${ newInputHeight }px`;
}
},
});

View File

@@ -0,0 +1,6 @@
import './assetstable/Table'
import './taskstable/Table'
import './shotstable/Table'
import './App'
import './activities/Activities'
import './detailedview/Viewer'

View File

@@ -0,0 +1,34 @@
let PillarTable = pillar.vuecomponents.table.PillarTable;
import {ShotsColumnFactory} from './columns/ShotsColumnFactory'
import {ShotRowsSource} from './rows/ShotRowsSource'
import {RowFilter} from '../attracttable/rows/filter/RowFilter'
let ShotsTable = Vue.component('attract-shots-table', {
extends: PillarTable,
props: {
project: Object
},
data() {
return {
columnFactory: new ShotsColumnFactory(this.project),
rowsSource: new ShotRowsSource(this.project._id),
rowFilterConfig: {validStatuses: this.getValidStatuses()}
}
},
methods: {
getValidStatuses() {
for (const it of this.project.node_types) {
if(it.name === 'attract_shot'){
return it.dyn_schema.status.allowed;
}
}
console.warn('Did not find allowed statuses for node type attract_shot');
return [];
}
},
components: {
'pillar-table-row-filter': RowFilter,
},
});
export { ShotsTable }

View File

@@ -0,0 +1,63 @@
let CellDefault = pillar.vuecomponents.table.cells.renderer.CellDefault;
const TEMPLATE =`
<div>
<img
v-if="img.src"
:src="img.src"
:alt="img.alt"
:height="img.height"
:width="img.width"
/>
<generic-placeholder
v-if="isLoading"
/>
</div>
`;
let CellPicture = Vue.component('pillar-cell-picture', {
extends: CellDefault,
template: TEMPLATE,
data() {
return {
img: {},
failed: false,
}
},
computed: {
isLoading() {
if(!this.failed) {
return !!this.rawCellValue && !this.img.src;
}
return false;
}
},
created() {
if (this.rawCellValue) {
this.loadThumbnail(this.rawCellValue);
}
},
watch: {
rawCellValue(newValue) {
this.loadThumbnail(newValue);
}
},
methods: {
loadThumbnail(imgId) {
this.img = {};
pillar.utils.thenLoadImage(imgId, 't')
.then(fileDoc => {
this.img = {
src: fileDoc.link,
alt: fileDoc.name,
width: fileDoc.width,
height: fileDoc.height,
}
}).fail(() => {
this.failed = true;
});
}
},
});
export { CellPicture }

View File

@@ -0,0 +1,15 @@
import {CellPicture} from '../cells/renderer/Picture'
let ColumnBase = pillar.vuecomponents.table.columns.ColumnBase;
export class Picture extends ColumnBase {
constructor() {
super('Thumbnail', 'thumbnail');
this.isSortable = false;
}
getCellRenderer(rowObject) {
return CellPicture.options.name;
}
getRawCellValue(rowObject) {
return rowObject.underlyingObject.picture;
}
}

View File

@@ -0,0 +1,31 @@
import { TaskColumn } from '../../attracttable/columns/Tasks';
import { FirstTaskDueDate, NextTaskDueDate, LastTaskDueDate } from '../../attracttable/columns/TaskDueDate';
import { Status } from '../../attracttable/columns/Status';
import { Picture } from '../columns/Picture'
import { RowObject } from '../../attracttable/columns/RowObject'
let ColumnFactoryBase = pillar.vuecomponents.table.columns.ColumnFactoryBase;
let Created = pillar.vuecomponents.table.columns.Created;
let Updated = pillar.vuecomponents.table.columns.Updated;
class ShotsColumnFactory extends ColumnFactoryBase{
constructor(project) {
super();
this.project = project;
}
thenGetColumns() {
let taskTypes = this.project.extension_props.attract.task_types.attract_shot;
let taskColumns = taskTypes.map((tType) => {
return new TaskColumn(tType, 'shot-task');
})
return Promise.resolve(
[new Status(), new Picture(), new RowObject()]
.concat(taskColumns)
.concat([new NextTaskDueDate(), new Created(), new Updated()])
);
}
}
export { ShotsColumnFactory }

View File

@@ -0,0 +1,46 @@
import {AttractRowBase} from '../../attracttable/rows/AttractRowBase'
import { TaskEventListener } from '../../attracttable/rows/TaskEventListener';
import { TaskRow } from '../../taskstable/rows/TaskRow';
class ShotRow extends AttractRowBase {
constructor(shot) {
super(shot);
this.tasks = [];
}
_thenInitImpl() {
return attract.api.thenGetTasks(this.getId())
.then((response) => {
this.tasks = response._items.map(t => new TaskRow(t));
this.registerTaskEventListeners();
return Promise.all(
this.tasks.map(t => t.thenInit())
);
})
}
registerTaskEventListeners() {
new TaskEventListener(this).register();
}
getTasksOfType(taskType) {
return this.tasks.filter((t) => {
return t.getProperties().task_type === taskType;
})
}
getRowClasses() {
let classes = super.getRowClasses()
if(this.isInitialized) {
classes['shot-not-in-edit'] = !this.underlyingObject.properties.used_in_edit;
}
return classes;
}
getChildObjects() {
return this.tasks;
}
}
export { ShotRow }

View File

@@ -0,0 +1,18 @@
import { AttractRowsSourceBase } from '../../attracttable/rows/AttractRowsSourceBase'
import { ShotRow } from './ShotRow'
class ShotRowsSource extends AttractRowsSourceBase {
constructor(projectId) {
super(projectId, 'attract_asset', ShotRow);
}
thenGetRowObjects() {
return attract.api.thenGetProjectShots(this.projectId)
.then((result) => {
let shots = result._items;
this.initRowObjects(shots);
});
}
}
export { ShotRowsSource }

View File

@@ -0,0 +1,64 @@
let PillarTable = pillar.vuecomponents.table.PillarTable;
import {TasksColumnFactory} from './columns/TasksColumnFactory'
import {TaskRowsSource} from './rows/TaskRowsSource'
import {RowFilter} from '../attracttable/rows/filter/RowFilter'
const TEMPLATE =`
<div class="pillar-table-actions">
<button class="action"
v-if="canAddTask"
@click="createNewTask"
>
<i class="pi-plus">New Task</i>
</button>
</div>
`;
let TableActions = {
template: TEMPLATE,
computed: {
canAddTask() {
let projectId = ProjectUtils.projectId();
return attract.auth.AttractAuth.canUserCreateTask(projectId);
}
},
methods: {
createNewTask(event) {
thenCreateTask(undefined, 'generic')
.then((task) => {
this.$emit('item-clicked', event, task._id);
});
}
},
}
let TasksTable = Vue.component('attract-tasks-table', {
extends: PillarTable,
props: {
project: Object
},
data() {
return {
columnFactory: new TasksColumnFactory(this.project),
rowsSource: new TaskRowsSource(this.project._id),
rowFilterConfig: {validStatuses: this.getValidStatuses()}
}
},
methods: {
getValidStatuses() {
for (const it of this.project.node_types) {
if(it.name === 'attract_task'){
return it.dyn_schema.status.allowed;
}
}
console.warn('Did not find allowed statuses for node type attract_task');
return [];
}
},
components: {
'pillar-table-actions': TableActions,
'pillar-table-row-filter': RowFilter,
}
});
export {TasksTable}

View File

@@ -0,0 +1,42 @@
let CellDefault = pillar.vuecomponents.table.cells.renderer.CellDefault;
const TEMPLATE =`
<div>
<a
v-if="rawCellValue"
@click="onClick"
:href="cellLink"
>
{{ cellValue }}
</a>
</div>
`;
let ParentNameCell = Vue.component('pillar-cell-parent-name', {
extends: CellDefault,
template: TEMPLATE,
computed: {
cellTitle() {
return this.rawCellValue;
},
cellLink() {
let project_url = ProjectUtils.projectUrl();
let item_type = this.itemType();
return `/attract/${project_url}/${item_type}s/${this.rowObject.getParent()._id}`;
},
embededLink() {
return this.cellLink;
}
},
methods: {
onClick(event) {
event.preventDefault(); // Don't follow link, but let event bubble and the row will handle it
},
itemType() {
let node_type = this.rowObject.getParent().node_type;
return node_type.replace('attract_', ''); // eg. attract_task to task
}
},
});
export { ParentNameCell }

View File

@@ -0,0 +1,30 @@
let ColumnBase = pillar.vuecomponents.table.columns.ColumnBase;
import {ParentNameCell} from '../cells/ParentName'
class ParentName extends ColumnBase {
constructor() {
super('Parent', 'parent-name');
}
getCellRenderer(rowObject) {
return ParentNameCell.options.name;
}
getRawCellValue(rowObject) {
if(!rowObject.getParent()) return '';
return rowObject.getParent().name || '<No Name>';
}
compareRows(rowObject1, rowObject2) {
let parent1 = rowObject1.getParent();
let parent2 = rowObject2.getParent();
if (parent1 && parent2) {
if (parent1.name === parent2.name) {
return parent1._id < parent2._id ? -1 : 1;
}
}
return super.compareRows(rowObject1, rowObject2);
}
}
export { ParentName }

View File

@@ -0,0 +1,13 @@
let ColumnBase = pillar.vuecomponents.table.columns.ColumnBase;
class ShortCode extends ColumnBase {
constructor() {
super('Short Code', 'short-code');
}
getRawCellValue(rowObject) {
return rowObject.getTask().properties.shortcode || '';
}
}
export { ShortCode }

View File

@@ -0,0 +1,13 @@
let ColumnBase = pillar.vuecomponents.table.columns.ColumnBase;
class TaskType extends ColumnBase {
constructor() {
super('Type', 'task-type');
}
getRawCellValue(rowObject) {
return rowObject.getTask().properties.task_type || '';
}
}
export { TaskType }

View File

@@ -0,0 +1,28 @@
import { Status } from '../../attracttable/columns/Status'
import { RowObject } from '../../attracttable/columns/RowObject'
import { TaskDueDate } from '../../attracttable/columns/TaskDueDate'
import { TaskType } from './TaskType'
import { ShortCode } from './ShortCode'
import { ParentName } from './ParentName'
let ColumnFactoryBase = pillar.vuecomponents.table.columns.ColumnFactoryBase;
let Created = pillar.vuecomponents.table.columns.Created;
let Updated = pillar.vuecomponents.table.columns.Updated;
class TasksColumnFactory extends ColumnFactoryBase{
thenGetColumns() {
return Promise.resolve([
new Status(),
new ParentName(),
new RowObject(),
new ShortCode(),
new TaskType(),
new TaskDueDate(),
new Created(),
new Updated(),
]);
}
}
export { TasksColumnFactory }

View File

@@ -0,0 +1,29 @@
import {AttractRowBase} from '../../attracttable/rows/AttractRowBase'
class TaskRow extends AttractRowBase {
constructor(task) {
super(task);
this.parent = undefined;
if (task.parent && task.parent._id) {
// Deattach parent from task to avoid parent to be overwritten when task is updated
let parentId = task.parent._id;
this.parent = task.parent;
task.parent = parentId;
pillar.events.Nodes.onUpdated(parentId, this.onParentUpdated.bind(this));
}
}
getTask() {
return this.underlyingObject;
}
getParent() {
return this.parent;
}
onParentUpdated(event) {
this.parent = event.detail;
}
}
export { TaskRow }

View File

@@ -0,0 +1,18 @@
import { AttractRowsSourceBase } from '../../attracttable/rows/AttractRowsSourceBase'
import { TaskRow } from './TaskRow'
class TaskRowsSource extends AttractRowsSourceBase {
constructor(projectId) {
super(projectId, 'attract_task', TaskRow);
}
thenGetRowObjects() {
return attract.api.thenGetProjectTasks(this.projectId)
.then((result) => {
let tasks = result._items;
this.initRowObjects(tasks);
});
}
}
export { TaskRowsSource }

View File

@@ -145,8 +145,8 @@ $(window).on('load resize', function(){
});
/* Fix header of items list (like assets or shots */
function itemsListFixHeader(toClone, toAppend){
/* Clone children elements, used to fix the header of items_list (like assets or shots) */
function cloneChildren(toClone, toAppend){
var target = $(toClone);
var target_children = target.children();
var clone = target.clone();
@@ -156,3 +156,45 @@ function itemsListFixHeader(toClone, toAppend){
});
$(toAppend).append(clone);
}
/* Scroll fixed headers horizontally, used for the header of items_list (like assets or shots) */
function scrollHeaderHorizontal(scrollableClassName, fixedContainer, offset){
var $table_list = $(scrollableClassName);
$table_list.scroll(function(e) {
// Scroll of the table scrollableClassName from the left minus offset, inverted (multiplied by -1)
var table_header_offset = ($table_list.scrollLeft() - offset) * - 1
$(fixedContainer).css('left', table_header_offset);
});
}
// For every column, set the width of the fixed header using the original columns width
function setHeaderCellsWidth(tableHeaderRowOriginal, tableHeaderRowFixed) {
var table_header = $(tableHeaderRowOriginal).children();
var table_header_fixed = $(tableHeaderRowFixed).children();
table_header_fixed.width(function(i,val) {
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 $('<span>')
.text(prefix + msg);
}

View File

@@ -1,133 +1,7 @@
/**
* Removes the task from the task list and shot list, and show the 'task-add-link'
* when this was the last task in its category.
*/
function _remove_task_from_list(task_id) {
var $task_link = $('#task-' + task_id)
var $task_link_parent = $task_link.parent();
$task_link.hideAndRemove(300, function() {
if ($task_link_parent.children('.task-link').length == 0) {
$task_link_parent.find('.task-add-link').removeClass('hidden');
}
});
}
/**
* Removes the 'active' class from any element whose ID starts with
* shot-, asset-, or task-.
*/
function deactivateItemLinks()
{
$('[id^="shot-"]').removeClass('active');
$('[id^="asset-"]').removeClass('active');
$('[id^="task-"]').removeClass('active');
}
/**
* Open an item such as tasks/shots in the #item-details div
*/
function item_open(item_id, item_type, pushState, project_url)
{
if (item_id === undefined || item_type === undefined) {
throw new ReferenceError("item_open(" + item_id + ", " + item_type + ") called.");
}
if (typeof project_url === 'undefined') {
project_url = ProjectUtils.projectUrl();
if (typeof project_url === 'undefined') {
throw new ReferenceError("ProjectUtils.projectUrl() undefined");
}
}
// Style elements starting with item_type and dash, e.g. "#shot-uuid"
deactivateItemLinks();
var current_item = $('#' + item_type + '-' + item_id);
current_item.addClass('processing');
// 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 (pc_ctx_shot_asset && item_type == 'task'){
push_url = '/attract/' + project_url + '/' + pu_ctx + 's/with-task/' + item_id;
}
item_url += '?context=' + pu_ctx;
statusBarSet('default', 'Loading ' + item_type + '…');
$.get(item_url, function(item_data) {
statusBarClear();
$('#item-details').html(item_data);
$('#col_right .col_header span.header_text').text(item_type + ' details');
current_item
.removeClass('processing newborn')
.addClass('active');
}).fail(function(xhr) {
if (console) {
console.log('Error fetching task', item_id, 'from', item_url);
console.log('XHR:', xhr);
}
statusBarSet('error', 'Failed to open ' + item_type, 'pi-warning');
if (xhr.status) {
$('#item-details').html(xhr.responseText);
} else {
$('#item-details').html('<p class="text-danger">Opening ' + item_type + ' failed. There possibly was ' +
'an error connecting to the server. Please check your network connection and ' +
'try again.</p>');
}
});
// Determine whether we should push the new state or not.
pushState = (typeof pushState !== 'undefined') ? pushState : true;
if (!pushState) return;
// Push the correct URL onto the history.
var push_state = {itemId: item_id, itemType: item_type};
window.history.pushState(
push_state,
item_type + ': ' + item_id,
push_url
);
}
// Fine if project_url is undefined, but that requires ProjectUtils.projectUrl().
function task_open(task_id, project_url)
{
item_open(task_id, 'task', true, project_url);
}
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;
item_open(state.itemId, state.itemType, false);
}
/**
* Create a asset and show it in the #item-details div.
*/
function asset_create(project_url)
function thenCreateAsset(project_url)
{
if (project_url === undefined) {
throw new ReferenceError("asset_create(" + project_url+ ") called.");
@@ -138,8 +12,9 @@ function asset_create(project_url)
project_url: project_url
};
$.post(url, data, function(asset_data) {
window.location.href = asset_data.asset_id;
return $.post(url, data, function(asset_data) {
pillar.events.Nodes.triggerCreated(asset_data);
return asset_data;
})
.fail(function(xhr) {
if (console) {
@@ -150,58 +25,6 @@ function asset_create(project_url)
});
}
/**
* Adds the task item to the shots/tasks list.
*
* 'shot_id' can be undefined if the task isn't attached to a shot.
*/
function task_add(shot_id, task_id, task_type)
{
if (task_id === undefined || task_type === undefined) {
throw new ReferenceError("task_add(" + shot_id + ", " + task_id + ", " + task_type + ") called.");
}
var project_url = ProjectUtils.projectUrl();
var url = '/attract/' + project_url + '/tasks/' + task_id;
var context = ProjectUtils.context();
if (context == 'task') {
/* WARNING: This is a copy of an element of attract/tasks/for_project .item-list.col-list
* If that changes, change this too. */
$('.item-list.task').append('\
<a class="col-list-item task-list-item status-todo task-link active"\
href="' + url + '"\
data-task-id="' + task_id + '"\
id="task-' + task_id + '">\
<span class="status-indicator"></span>\
<span class="name">-save your task first-</span>\
<span class="due_date">-</span>\
</a>\
');
} else if (context == 'shot' || context == 'asset') {
if (shot_id === undefined) {
throw new ReferenceError("task_add(" + shot_id + ", " + task_id + ", " + task_type + ") called in " + context + " context.");
}
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 .item-list.col-list
* If that changes, change this too. */
$list_cell.append('\
<a class="status-todo task-link active newborn"\
title="-save your task first-"\
href="' + url + '"\
data-task-id="' + task_id + '"\
id="task-' + task_id + '">\
</a>\
');
$list_cell.find('.task-add.task-add-link').addClass('hidden');
} else {
if (console) console.log('task_add: not doing much in context', context);
}
}
/**
* Create a task and show it in the #item-details div.
@@ -209,7 +32,7 @@ function task_add(shot_id, task_id, task_type)
* 'shot_id' may be undefined, in which case the task will not
* be attached to a shot.
*/
function task_create(shot_id, task_type)
function thenCreateTask(shot_id, task_type)
{
if (task_type === undefined) {
throw new ReferenceError("task_create(" + shot_id + ", " + task_type + ") called.");
@@ -224,10 +47,10 @@ function task_create(shot_id, task_type)
};
if (has_shot_id) data.parent = shot_id;
$.post(url, data, function(task_data) {
return $.post(url, data, function(task_data) {
if (console) console.log('Task created:', task_data);
task_open(task_data.task_id);
task_add(shot_id, task_data.task_id, task_type);
pillar.events.Nodes.triggerCreated(task_data);
return task_data;
})
.fail(function(xhr) {
if (console) {
@@ -235,235 +58,9 @@ function task_create(shot_id, task_type)
console.log('XHR:', xhr);
}
$('#item-details').html(xhr.responseText);
})
.done(function(){
$('#item-details input[name="name"]').focus();
});
}
function attract_form_save(form_id, item_id, item_save_url, options)
{
// Mandatory option.
if (typeof options === 'undefined' || typeof options.type === 'undefined') {
throw new ReferenceError('attract_form_save(): options.type is mandatory.');
}
var $form = $('#' + form_id);
var $button = $form.find("button[type='submit']");
var payload = $form.serialize();
var $item = $('#' + item_id);
$button.attr('disabled', true);
$item.addClass('processing');
statusBarSet('', 'Saving ' + options.type + '…');
if (console) console.log('Sending:', payload);
$.post(item_save_url, payload)
.done(function(saved_item) {
if (console) console.log('Done saving', saved_item);
statusBarSet('success', 'Saved ' + options.type + '. ' + saved_item._updated, 'pi-check');
$form.find("input[name='_etag']").val(saved_item._etag);
if (options.done) options.done($item, saved_item);
})
.fail(function(xhr_or_response_data) {
// jQuery sends the response data (if JSON), or an XHR object (if not JSON).
if (console) console.log('Failed saving', options.type, xhr_or_response_data);
$button.removeClass('btn-default').addClass('btn-danger');
statusBarSet('error', 'Failed saving. ' + xhr_or_response_data.status, 'pi-warning');
if (options.fail) options.fail($item, xhr_or_response_data);
})
.always(function() {
$button.attr('disabled', false);
$item.removeClass('processing');
if (options.always) options.always($item);
})
;
return false; // prevent synchronous POST to current page.
}
function task_save(task_id, task_url) {
return attract_form_save('item_form', 'task-' + task_id, task_url, {
done: function($task, saved_task) {
// Update the task list.
// NOTE: this is tightly linked to the HTML of the task list in for_project.jade.
$('.task-name-' + saved_task._id).text(saved_task.name).flashOnce();
$task.find('span.name').text(saved_task.name);
$task.find('span.status').text(saved_task.properties.status.replace('_', ' '));
if (saved_task.properties.due_date){
$task.find('span.due_date').text(moment().to(saved_task.properties.due_date));
}
$task
.removeClassPrefix('status-')
.addClass('status-' + saved_task.properties.status)
.flashOnce()
;
task_open(task_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: 'task'
});
}
function shot_save(shot_id, shot_url) {
return attract_form_save('item_form', 'shot-' + shot_id, shot_url, {
done: function($shot, saved_shot) {
// Update the shot list.
$('.shot-name-' + saved_shot._id).text(saved_shot.name);
$shot
.removeClassPrefix('status-')
.addClass('status-' + saved_shot.properties.status)
.flashOnce()
;
shot_open(shot_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: 'shot'
});
}
function asset_save(asset_id, asset_url) {
return attract_form_save('item_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.
$('.item-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.");
}
$('#task-' + task_id).addClass('processing');
$.ajax({
type: 'DELETE',
url: task_delete_url,
data: {'etag': task_etag}
})
.done(function(e) {
if (console) console.log('Task', task_id, 'was deleted.');
$('#item-details').fadeOutAndClear();
_remove_task_from_list(task_id);
statusBarSet('success', 'Task deleted successfully', 'pi-check');
})
.fail(function(xhr) {
statusBarSet('error', 'Unable to delete task, code ' + xhr.status, 'pi-warning');
if (xhr.status == 412) {
alert('Someone else edited this task before you deleted it; refresh to try again.');
// 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.
// TODO: refresh activity feed and point user to it.
} else {
// TODO: find a better place to put this error message, without overwriting the
// task the user is looking at in-place.
$('#task-view-feed').html(xhr.responseText);
}
});
}
function loadActivities(url)
{
return $.get(url)
.done(function(data) {
if(console) console.log('Activities loaded OK');
$('#activities').html(data);
})
.fail(function(xhr) {
if (console) {
console.log('Error fetching activities');
console.log('XHR:', xhr);
}
statusBarSet('error', 'Opening activity log failed.', 'pi-warning');
if (xhr.status) {
$('#activities').html(xhr.responseText);
} else {
$('#activities').html('<p class="text-danger">Opening activity log failed. There possibly was ' +
'an error connecting to the server. Please check your network connection and ' +
'try again.</p>');
}
});
}
$(function() {
$("a.shot-link[data-shot-id]").click(function(e) {
e.preventDefault();
// delegateTarget is the thing the event hander was attached to,
// rather than the thing we clicked on.
var shot_id = e.delegateTarget.dataset.shotId;
shot_open(shot_id);
});
$("a.asset-link[data-asset-id]").click(function(e) {
e.preventDefault();
// delegateTarget is the thing the event hander was attached to,
// rather than the thing we clicked on.
var asset_id = e.delegateTarget.dataset.assetId;
asset_open(asset_id);
});
$("a.task-link[data-task-id]").click(function(e) {
e.preventDefault();
var task_id = e.delegateTarget.dataset.taskId;
var project_url = e.delegateTarget.dataset.projectUrl; // fine if undefined
task_open(task_id, project_url);
});
});
var save_on_ctrl_enter = ['shot', 'asset', 'task'];
$(document).on('keyup', function(e){
if ($.inArray(save_on_ctrl_enter, ProjectUtils.context())) {

View File

@@ -53,6 +53,9 @@ nav.sidebar
margin-bottom: 10px
width: 100%
option
@include status-color-property(color, '', 'dark')
button
&#item-save
+button($color-success, 3px)
@@ -69,13 +72,15 @@ nav.sidebar
.table-cell
&.item-status
width: 5px
min-width: 5px
height: 100%
border-bottom: none
.table-head
&.is-fixed
position: fixed
top: 42px
top: 84px
z-index: 1
pointer-events: none
background-color: white
.table-cell
@@ -240,6 +245,9 @@ nav.sidebar
color: $color-text-dark-secondary
margin-right: 10px
span.item-extra
white-space: nowrap
padding-right: 10px
/* Debug styles, such as status color legend on help */
.debug-info
@@ -279,67 +287,6 @@ nav.sidebar
@include status-color-property(background-color, '', 'dark')
/* General style for activities in all places */
.d-activity
font-size: .9em
$activity-highlight-color: #00cc9f
ul
cursor: default
padding: 5px
color: $color-text-dark-primary
list-style: none
li
padding: 0 10px 7px 10px
position: relative
span.date
color: darken($activity-highlight-color, 5%)
/* Left Dot */
&:after
content: ''
display: block
position: absolute
top: 6px
left: -3px
width: 5px
height: 5px
border-radius: 50%
background-color: $color-background-light
border: thin solid $activity-highlight-color
transition: all 250ms ease-in-out
/* Left Line */
&:before
content: ''
display: block
position: absolute
top: 10px
left: -1px
width: 1px
height: 100%
background-color: $activity-highlight-color
transition: all 250ms ease-in-out
&:last-child
&:before
background-color: transparent
span.actor
padding: 0 5px
color: $color-text-dark
img.actor-avatar
width: 16px
height: 16px
border-radius: 50%
margin-right: 5px
position: relative
top: -2px
.attract-box
.item-id
padding: 8px
@@ -354,6 +301,13 @@ nav.sidebar
&:hover
color: $color-text-dark-primary
border-color: $color-text-dark-primary
textarea
overflow-y: hidden // there is js in place to make them grow as needed instead
resize: none
.edited
background-color: $color-status-updated
#item-details
@@ -380,3 +334,15 @@ nav.sidebar
#comments-container
margin-top: 0
.attract-app
display: flex
width: 100%
.attract-detailed-view
overflow: scroll
.col_header
position: sticky
top: 0px
z-index: $zindex-sticky

View File

@@ -1,615 +1 @@
/* Collection of mixins that can be plugged everywhere */
=clearfix
clear: both
&:after
// Basically same as .clearfix from bootstrap
clear: both
display: block
content: ' '
@mixin button($mixin-color, $roundness, $filled: false)
font-family: $font-body
text-transform: uppercase
opacity: .9
padding:
left: 20px
right: 20px
border-radius: $roundness
@if $filled
background: linear-gradient(lighten($mixin-color, 2%), $mixin-color)
color: white
border: thin solid darken($mixin-color, 5%)
text-shadow: 1px 1px 0 rgba(black, .15)
@else
background-color: transparent
color: $mixin-color
border: thin solid $mixin-color
text-shadow: none
transition: color 350ms ease-out, border 150ms ease-in-out, opacity 150ms ease-in-out, background-color 150ms ease-in-out
&:hover
opacity: 1
cursor: pointer
text-decoration: none
@if $filled
background: linear-gradient(lighten($mixin-color, 5%), lighten($mixin-color, 5%))
color: white
border-color: lighten($mixin-color, 5%)
@else
background-color: rgba($mixin-color, .1)
color: $mixin-color
border-color: $mixin-color
&:active, &:focus
outline: none
border-color: $mixin-color
background-color: $mixin-color
color: white
i
margin-right: 10px
small
font-size: .6em
&:disabled
cursor: not-allowed
color: $color-text-dark-secondary
border-color: $color-text-dark-hint
&:hover
@if $filled
background: rgba($color-text-dark-hint, .2)
@else
background-color: rgba($color-text-dark-hint, .1)
@if $filled
background: rgba($color-text-dark-hint, .1)
text-shadow: none
@mixin overlay($from-color, $from-percentage, $to-color, $to-percentage)
position: absolute
top: 0
left: 0
right: 0
bottom: 0
background: linear-gradient(to bottom, $from-color $from-percentage, $to-color $to-percentage)
@mixin stripes($color-light, $color-dark, $deg, $size)
background-size: $size $size
background-image: linear-gradient($deg, $color-light 25%, $color-dark 25%, $color-dark 50%, $color-light 50%, $color-light 75%, $color-dark 75%, $color-dark)
=stripes-animate
animation:
name: background-slide
duration: 1s
delay: 0s
iteration-count: infinite
timing-function: linear
=container-box
position: relative
background-color: white
border-radius: 3px
box-shadow: rgba(0, 0, 0, 0.298039) 0px 1px 4px -1px
=text-overflow-ellipsis
overflow: hidden
white-space: nowrap
text-overflow: ellipsis
=position-center-translate
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
=input-generic
color: $color-text-dark
box-shadow: none
font-family: $font-body
border-radius: 3px
border-color: $color-background-dark
background-color: $color-background-light
&:focus
border-color: $color-info
box-shadow: none
=label-generic
color: $color-text-dark
font-family: $font-body
font-weight: 300
@mixin badge($mixin-color, $roundness)
padding:
left: 10px
right: 10px
text-transform: uppercase
color: $mixin-color
border: 1px solid $mixin-color
border-radius: $roundness
i
margin-right: 10px
/* Smallest, like phones on portrait.
** Menu is collapsed, columns stack, no brand */
=media-xs
@media (max-width: #{$screen-tablet - 1px})
@content
/* Small but wide: phablets, iPads
** Menu is collapsed, columns stack, no brand */
=media-sm
@media (min-width: #{$screen-tablet}) and (max-width: #{$screen-desktop - 1px})
@content
/* Tablets portrait.
** Menu is expanded, but columns stack, brand is shown */
=media-md
@media (min-width: #{$screen-desktop})
@content
=media-lg
@media (min-width: #{$screen-lg-desktop})
@content
=media-print
@media print
@content
=spin
animation:
name: spin-once
duration: 1s
delay: 0s
fill-mode: forwards
iteration-count: infinite
timing-function: linear
=spin-once
+spin
animation:
iteration-count: 1
=pulse
animation:
name: pulse
duration: 1s
delay: 0s
fill-mode: forwards
iteration-count: infinite
=pulse-75
animation:
name: pulse-75
duration: 1s
delay: 0
fill-mode: forwards
iteration-count: infinite
=animation-wiggle
animation:
name: wiggle
duration: 1s
delay: 0s
fill-mode: forwards
iteration-count: infinite
timing-function: linear
.spin
+spin
&:before, &:after
+spin
@keyframes spin-once
from
transform: rotate(0deg)
to
transform: rotate(360deg)
@keyframes wiggle
0
transform: rotate(0deg)
25%
transform: rotate(25deg)
75%
transform: rotate(-25deg)
100%
transform: rotate(0deg)
@keyframes pulse
0
opacity: 1
50%
opacity: 0
100%
opacity: 1
@keyframes pulse-75
0
opacity: 1
50%
opacity: .8
100%
opacity: 1
@keyframes background-fill-left-right
from
background-position: right bottom
to
background-position: left bottom
@keyframes grow-bounce-in
0
transform: scale(0.8)
opacity: 0
50%
transform: scale(1.05)
opacity: 1
85%
transform: scale(1.0)
90%
transform: scale(0.99)
100%
transform: scale(1.0)
@keyframes grow-bounce-out
0
transform: scale(1.0)
opacity: 1
100%
transform: scale(0.9)
opacity: 0
@keyframes background-slide
from
background-position: 0 0
to
background-position: 50px 50px
@keyframes grow-bounce
0
transform: scale(1.0)
opacity: 1
50%
transform: scale(1.01)
opacity: .9
85%
transform: scale(1.0)
90%
transform: scale(0.99)
opacity: 1
100%
transform: scale(1.0)
@keyframes grow-bounce-heartbeat
0
transform: scale(1.0)
85%
transform: scale(1.0)
90%
transform: scale(1.15)
94%
transform: scale(0.9)
96%
transform: scale(1.05)
100%
transform: scale(1.0)
=list-bullets
ul
padding-left: 20px
list-style: none
li:before
content: '·'
font-weight: 400
position: relative
left: -10px
=node-details-description
padding: 15px 0 25px 0
color: darken($color-text-dark, 5%)
font:
family: $font-body
weight: 300
size: 1.2em
word-break: break-word
clear: both
+clearfix
+media-xs
font-size: 1.1em
strong, b
font-weight: 400
a:not([class])
color: $color-text-dark-primary
text-decoration: underline
&:hover
color: $color-primary
p
padding:
left: 20px
right: 20px
margin-bottom: 20px
line-height: 1.5em
word-wrap: break-word
h1, h2, h3, h4, h5, h6
padding:
top: 20px
left: 20px
right: 20px
blockquote
background-color: lighten($color-background, 5%)
text-shadow: 1px 1px 0 rgba(white, .2)
margin:
left: 20px
right: 20px
bottom: 30px
font-size: 1em
p
padding: 0
margin: 0
ul li blockquote
margin:
left: 0
top: 15px
img,
p img,
ul li img
max-width: 100%
padding:
top: 25px
// bottom: 10px
bottom: 25px
h2
margin-bottom: 15px
+media-xs
font-size: 1.5em
/* e.g. YouTube embed */
iframe
margin-top: 20px
width: 100%
max-width: 100%
height: auto
min-height: 354px
+media-sm
iframe
min-height: 314px
+media-xs
iframe
min-height: 314px
iframe[src^="https://w.soundcloud"]
min-height: auto
+list-bullets
ul
padding-left: 40px
margin-bottom: 25px
li
margin-bottom: 7px
img
display: block
padding:
top: 25px
bottom: 10px
ul, ul li ul
margin-top: 15px
padding-left: 20px
code, kbd, pre, samp
font-size: 1.3rem
pre
background-color: lighten($color-background, 5%)
border-color: $color-background
border-radius: 3px
color: $color-text
/* when <pre> is outside <p> */
margin:
left: 20px
right: 20px
pre+p
margin-top: 30px
p+pre
/* a <pre> right after a <p> usually are related, remove some spacing */
margin-top: -10px
p
pre
/* We already have spacing on the sides inside <p> */
margin:
left: 0
right: 0
=markdown-preview-container
border:
top: 1px solid $color-background
bottom: 1px solid $color-background
position: relative
margin: 40px auto 25px auto
padding: 10px 10px 25px 10px
color: $color-text-dark-primary
cursor: default
transition: all 150ms ease-in-out
+node-details-description
// Funny, normalize.css doesn't normalize when it's outside
h1
font-size: 2.8em
h2
margin-bottom: 15px
=ribbon
background-color: $color-success
cursor: default
overflow: hidden
white-space: nowrap
position: absolute
right: -40px
top: 10px
-webkit-transform: rotate(45deg)
-moz-transform: rotate(45deg)
-ms-transform: rotate(45deg)
-o-transform: rotate(45deg)
transform: rotate(45deg)
span
border: thin dashed rgba(white, .5)
color: white
display: block
font-size: 70%
margin: 1px 0
padding: 3px 50px
text:
align: center
transform: uppercase
@mixin text-background($text-color, $background-color, $roundness, $padding)
border-radius: $roundness
padding: $padding
background-color: rgba($background-color, .9)
box-shadow: 0.5em 0 0 rgba($background-color, .9),-0.5em 0 0 rgba($background-color, .9)
box-decoration-break: clone
color: $text-color
=list-meta
margin: 0
padding: 0
list-style: none
color: $color-text-dark-primary
li
display: inline-block
padding-left: 15px
position: relative
&:before
content: '·'
position: relative
top: 1px
left: -7px
color: $color-text-dark-secondary
&:first-child
padding-left: 0
&:before
content: ''
a
color: $color-text-dark-secondary
&:hover
color: $color-primary
/* Bootstrap's img-responsive class */
=img-responsive
display: block
max-width: 100%
height: auto
/* Set the color for a specified property
* 1: $property: e.g. background-color
* 2: $where: ':before', ' .class-name', etc.
* 3: $variation: 'light', 'dark', or empty
* e.g. @include status-color-property(background-color, ':before', 'light')
*/
@mixin status-color-property($property, $where: false, $variation: false)
@if not ($where)
$where: ''
&.status
&-invalid#{$where}
@if ($variation == 'light')
#{$property}: $color-status-invalid-light
@else if ($variation == 'dark')
#{$property}: $color-status-invalid-dark
@else
#{$property}: $color-status-invalid
&-todo#{$where}
@if ($variation == 'light')
#{$property}: $color-status-todo-light
@else if ($variation == 'dark')
#{$property}: $color-status-todo-dark
@else
#{$property}: $color-status-todo
&-in_progress#{$where}
@if ($variation == 'light')
#{$property}: $color-status-in_progress-light
@else if ($variation == 'dark')
#{$property}: $color-status-in_progress-dark
@else
#{$property}: $color-status-in_progress
&-on_hold#{$where}
@if ($variation == 'light')
#{$property}: $color-status-on_hold-light
@else if ($variation == 'dark')
#{$property}: $color-status-on_hold-dark
@else
#{$property}: $color-status-on_hold
&-approved#{$where}
@if ($variation == 'light')
#{$property}: $color-status-approved-light
@else if ($variation == 'dark')
#{$property}: $color-status-approved-dark
@else
#{$property}: $color-status-approved
&-cbb#{$where}
@if ($variation == 'light')
#{$property}: $color-status-cbb-light
@else if ($variation == 'dark')
#{$property}: $color-status-cbb-dark
@else
#{$property}: $color-status-cbb
&-final#{$where}
@if ($variation == 'light')
#{$property}: $color-status-final-light
@else if ($variation == 'dark')
#{$property}: $color-status-final-dark
@else
#{$property}: $color-status-final
&-review#{$where}
@if ($variation == 'light')
#{$property}: $color-status-review-light
@else if ($variation == 'dark')
#{$property}: $color-status-review-dark
@else
#{$property}: $color-status-review
=sidebar-button-active
background-color: $color-background-nav-light
box-shadow: inset 2px 0 0 $color-primary
color: white

View File

@@ -1,95 +1,3 @@
$color-background: #eaebec
$color-background-light: lighten($color-background, 5%)
$color-background-dark: darken($color-background, 5%)
$color-background-nav: hsl(hue($color-background), 20%, 25%)
$color-background-nav-light: hsl(hue($color-background), 20%, 35%)
$color-background-nav-dark: hsl(hue($color-background), 20%, 15%)
$color-background-active: #dff5f6 // background colour for active items.
$font-body: 'Roboto'
$font-headings: 'Lato'
$font-size: 14px
$color-text: #4d4e53
$color-text-dark: $color-text
$color-text-dark-primary: #646469 // rgba($color-text, .87)
$color-text-dark-secondary: #9E9FA2 // rgba($color-text, .54)
$color-text-dark-hint: #BBBBBD // rgba($color-text, .38)
$color-text-light: white
$color-text-light-primary: rgba($color-text-light, .87)
$color-text-light-secondary: rgba($color-text-light, .54)
$color-text-light-hint: rgba($color-text-light, .38)
$color-primary: #68B3C8
$color-primary-light: hsl(hue($color-primary), 30%, 90%)
$color-primary-dark: hsl(hue($color-primary), 80%, 30%)
$color-primary-accent: hsl(hue($color-primary), 100%, 50%)
$color-secondary: #f42942
$color-secondary-light: hsl(hue($color-secondary), 30%, 90%)
$color-secondary-dark: hsl(hue($color-secondary), 80%, 40%)
$color-secondary-accent: hsl(hue($color-secondary), 100%, 50%)
$color-warning: #F3BB45 !default
$color-info: #68B3C8 !default
$color-success: #27AE60 !default
$color-danger: #EB5E28 !default
/* Borrowed from dillo.space :) */
$color_upvote: #ff8b60
$color_downvote: #74a4ff
/* Label Status */
$color-status-invalid: #999
$color-status-invalid-light: lighten($color-status-invalid, 10%)
$color-status-invalid-dark: darken($color-status-invalid, 10%)
$color-status-todo: #ff8080
$color-status-todo-light: hsl(hue($color-status-todo), 100%, 85%)
$color-status-todo-dark: hsl(hue($color-status-todo), 100%, 65%)
$color-status-on_hold: #cb9e15
$color-status-on_hold-light: hsl(hue($color-status-on_hold), 50%, 70%)
$color-status-on_hold-dark: hsl(hue($color-status-on_hold), 60%, 40%)
$color-status-in_progress: #ffbe00
$color-status-in_progress-light: hsl(hue($color-status-in_progress), 100%, 55%)
$color-status-in_progress-dark: hsl(hue($color-status-in_progress), 100%, 45%)
$color-status-review: #00ceff
$color-status-review-light: hsl(hue($color-status-review), 100%, 75%)
$color-status-review-dark: hsl(hue($color-status-review), 100%, 40%)
$color-status-approved: #00cc9f
$color-status-approved-light: hsl(hue($color-status-approved), 100%, 70%)
$color-status-approved-dark: hsl(hue($color-status-approved), 100%, 35%)
$color-status-cbb: #acbf92
$color-status-cbb-light: hsl(hue($color-status-cbb), 40%, 75%)
$color-status-cbb-dark: hsl(hue($color-status-cbb), 15%, 50%)
$color-status-final: #b0ea10
$color-status-final-light: hsl(hue($color-status-final), 100%, 70%)
$color-status-final-dark: hsl(hue($color-status-final), 100%, 30%)
$color-status-active: #E6F3FD
$color-status-updated: #e7f5d3
/* Mobile Stuff */
$screen-xs: 480px !default
$screen-xs-min: $screen-xs
$screen-phone: $screen-xs-min
$screen-sm: 768px !default
$screen-sm-min: $screen-sm
$screen-tablet: $screen-sm-min
$screen-md: 1100px !default
$screen-md-min: $screen-md
$screen-desktop: $screen-md-min
$screen-lg: 1270px !default
$screen-lg-min: $screen-lg
$screen-lg-desktop: $screen-lg-min
$screen-xs-max: $screen-sm-min - 1
$screen-sm-max: $screen-md-min - 1
$screen-md-max: $screen-lg-min - 1
$sidebar-width: 50px
/* Attract specific configuration settings */
$items-list-thumbnail-width: 100px

View File

@@ -88,16 +88,21 @@
/* Dashboard specific styles */
.dashboard
.d-stats
padding: 0 10px
.d-stats-card
+container-box
padding: 10px
.progress
margin-bottom: 0
h4
margin-top: 0
margin-bottom: 10px
height: 4px
.d-stats-card-legend
display: flex
justify-content: space-between
font-size: .95em
.d-stats-card-item
@include status-color-property(color, '', 'dark')
.d-activity
margin: 0 20px 15px

View File

@@ -9,6 +9,8 @@
&.with-status
border-top: thick solid $color-background-dark
@include status-color-property(border-top-color, '', 'dark')
@include status-color-property(color, ' select', 'dark')
@include status-color-property(border-bottom-color, ' select', 'dark')
.item-name
font-size: 1.6em

View File

@@ -0,0 +1,62 @@
.pillar-table-container.attract-tasks-table
.pillar-table-row
min-height: 1.4em
.pillar-table-container.attract-shots-table
.pillar-table-row
min-height: $thumbnail-max-height
&.shot-not-in-edit
+stripes(transparent, rgba($color-warning, .2), -45deg, 4em)
.pillar-table-container.attract-assets-table
.pillar-table-row
min-height: 2.6em
.pillar-cell
&.thumbnail
padding-left: 0px
&.row-object
flex-basis: 3em
&.attract-status
flex: 0
flex-basis: 1em
min-width: 1em
&.task-type
text-transform: capitalize
.add-task-link
opacity: 0
cursor: pointer
vertical-align: middle
color: $color-primary
border: none
background: none
width: max-content
padding: 0
&:hover
text-decoration-line: underline
&:hover
.add-task-link
opacity: 1
.tasks
display: flex
.task
@include status-color-property(background-color, '', '')
width: 1em
height: 1em
border-radius: 1em
&:hover
box-shadow: inset 0px 0px 5px $color-background-active-dark
&.active
border: 2px solid white
box-shadow: 0 0 0 1px rgba($color-primary, .2), 1px 1px 0 rgba(black, .2)

Some files were not shown because too many files have changed in this diff Show More