Added shot summary per project on /attract
This commit is contained in:
@@ -2,6 +2,7 @@ import logging
|
|||||||
|
|
||||||
import attract.shots
|
import attract.shots
|
||||||
import flask
|
import flask
|
||||||
|
import flask_login
|
||||||
from werkzeug.local import LocalProxy
|
from werkzeug.local import LocalProxy
|
||||||
from pillar.extension import PillarExtension
|
from pillar.extension import PillarExtension
|
||||||
|
|
||||||
@@ -86,6 +87,27 @@ class AttractExtension(PillarExtension):
|
|||||||
tasks.setup_app(app)
|
tasks.setup_app(app)
|
||||||
eve_hooks.setup_app(app)
|
eve_hooks.setup_app(app)
|
||||||
|
|
||||||
|
def attract_projects(self):
|
||||||
|
"""Returns projects set up for Attract.
|
||||||
|
|
||||||
|
:returns: {'_items': [proj, proj, ...], '_meta': Eve metadata}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pillarsdk
|
||||||
|
from pillar.web.system_util import pillar_api
|
||||||
|
from .node_types.shot import node_type_shot
|
||||||
|
|
||||||
|
api = pillar_api()
|
||||||
|
|
||||||
|
# Find projects that are set up for Attract.
|
||||||
|
projects = pillarsdk.Project.all({
|
||||||
|
'where': {
|
||||||
|
'extension_props.attract': {'$exists': 1},
|
||||||
|
'node_types.name': node_type_shot['name'],
|
||||||
|
}}, api=api)
|
||||||
|
|
||||||
|
return projects
|
||||||
|
|
||||||
|
|
||||||
def _get_current_attract():
|
def _get_current_attract():
|
||||||
"""Returns the Attract extension of the current application."""
|
"""Returns the Attract extension of the current application."""
|
||||||
|
@@ -19,11 +19,19 @@ def index():
|
|||||||
user = flask_login.current_user
|
user = flask_login.current_user
|
||||||
if user.is_authenticated:
|
if user.is_authenticated:
|
||||||
tasks = current_attract.task_manager.tasks_for_user(user.objectid)
|
tasks = current_attract.task_manager.tasks_for_user(user.objectid)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
tasks = None
|
tasks = None
|
||||||
|
|
||||||
|
projects = current_attract.attract_projects()
|
||||||
|
projs_with_summaries = [
|
||||||
|
(proj, current_attract.shot_manager.shot_status_summary(proj['_id']))
|
||||||
|
for proj in projects['_items']
|
||||||
|
]
|
||||||
|
|
||||||
return render_template('attract/index.html',
|
return render_template('attract/index.html',
|
||||||
tasks=tasks)
|
tasks=tasks,
|
||||||
|
projs_with_summaries=projs_with_summaries)
|
||||||
|
|
||||||
|
|
||||||
def error_project_not_setup_for_attract():
|
def error_project_not_setup_for_attract():
|
||||||
|
@@ -35,6 +35,38 @@ VALID_PATCH_OPERATIONS = {
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectSummary(object):
|
||||||
|
"""Summary of the shots in a project."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._counts = collections.defaultdict(int)
|
||||||
|
self._total = 0
|
||||||
|
|
||||||
|
def count(self, status):
|
||||||
|
self._counts[status] += 1
|
||||||
|
self._total += 1
|
||||||
|
|
||||||
|
def percentages(self):
|
||||||
|
"""Generator, yields (status, percentage) tuples.
|
||||||
|
|
||||||
|
The percentage is on a 0-100 scale.
|
||||||
|
"""
|
||||||
|
|
||||||
|
remaining = 100
|
||||||
|
last_index = len(self._counts) - 1
|
||||||
|
|
||||||
|
for idx, status in enumerate(sorted(self._counts.keys())):
|
||||||
|
if idx == last_index:
|
||||||
|
yield (status, remaining)
|
||||||
|
continue
|
||||||
|
|
||||||
|
perc = float(self._counts[status]) / self._total
|
||||||
|
whole_perc = int(round(perc * 100))
|
||||||
|
remaining -= whole_perc
|
||||||
|
|
||||||
|
yield (status, whole_perc)
|
||||||
|
|
||||||
|
|
||||||
@attr.s
|
@attr.s
|
||||||
class ShotManager(object):
|
class ShotManager(object):
|
||||||
_log = attrs_extra.log('%s.ShotManager' % __name__)
|
_log = attrs_extra.log('%s.ShotManager' % __name__)
|
||||||
@@ -135,6 +167,35 @@ class ShotManager(object):
|
|||||||
|
|
||||||
shot.patch(patch, api=api)
|
shot.patch(patch, api=api)
|
||||||
|
|
||||||
|
def shot_status_summary(self, project_id):
|
||||||
|
"""Returns number of shots per shot status for the given project.
|
||||||
|
|
||||||
|
:rtype: ProjectSummary
|
||||||
|
"""
|
||||||
|
|
||||||
|
api = pillar_api()
|
||||||
|
|
||||||
|
# TODO: turn this into an aggregation call to do the counting on MongoDB.
|
||||||
|
shots = pillarsdk.Node.all({
|
||||||
|
'where': {
|
||||||
|
'node_type': node_type_shot['name'],
|
||||||
|
'project': project_id,
|
||||||
|
},
|
||||||
|
'projection': {
|
||||||
|
'properties.status': 1,
|
||||||
|
},
|
||||||
|
'order': [
|
||||||
|
('properties.status', 1),
|
||||||
|
],
|
||||||
|
}, api=api)
|
||||||
|
|
||||||
|
# FIXME: this breaks when we hit the pagination limit.
|
||||||
|
summary = ProjectSummary()
|
||||||
|
for shot in shots['_items']:
|
||||||
|
summary.count(shot['properties']['status'])
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
def node_setattr(node, key, value):
|
def node_setattr(node, key, value):
|
||||||
"""Sets a node property by dotted key.
|
"""Sets a node property by dotted key.
|
||||||
|
@@ -19,15 +19,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@blueprint.route('/')
|
@blueprint.route('/')
|
||||||
def index():
|
def index():
|
||||||
api = pillar_api()
|
projects = current_attract.attract_projects()
|
||||||
|
|
||||||
# Find projects that are set up for Attract.
|
|
||||||
projects = pillarsdk.Project.all({
|
|
||||||
'where': {
|
|
||||||
'extension_props.attract': {'$exists': 1},
|
|
||||||
'node_types.name': node_type_shot['name'],
|
|
||||||
}}, api=api)
|
|
||||||
|
|
||||||
return render_template('attract/shots/index.html',
|
return render_template('attract/shots/index.html',
|
||||||
projects=projects['_items'])
|
projects=projects['_items'])
|
||||||
|
|
||||||
|
@@ -197,3 +197,9 @@ select.input-transparent
|
|||||||
|
|
||||||
.btn:active
|
.btn:active
|
||||||
box-shadow: none
|
box-shadow: none
|
||||||
|
|
||||||
|
.bg-status
|
||||||
|
@include status-color-property(background-color, '', 'dark')
|
||||||
|
|
||||||
|
.fg-status
|
||||||
|
@include status-color-property(color, '', 'dark')
|
||||||
|
@@ -24,13 +24,19 @@
|
|||||||
span Activity Stuff
|
span Activity Stuff
|
||||||
- }
|
- }
|
||||||
|
|
||||||
|
h3 Shot statistics
|
||||||
|
|
||||||
h3 Stats
|
| {% for proj, summary in projs_with_summaries %}
|
||||||
|
h4
|
||||||
|
a(href="{{ url_for('attract.shots.perproject.index', project_url=proj.url) }}") {{ proj.name }}
|
||||||
.progress
|
.progress
|
||||||
.progress-bar.progress-bar-success(role="progressbar", style="width:50%")
|
| {% for status, percentage in summary.percentages() %}
|
||||||
| Final
|
.progress-bar.bg-status(
|
||||||
.progress-bar.progress-bar-info(role="progressbar", style="width:30%")
|
class="status-{{status}}",
|
||||||
| Review
|
title="{{ status | undertitle }}",
|
||||||
.progress-bar.progress-bar-danger(role="progressbar", style="width:20%")
|
role="progressbar",
|
||||||
| ToDo
|
style="width:{{percentage}}%")
|
||||||
|
| {{ status | undertitle }}
|
||||||
|
| {% endfor %}
|
||||||
|
| {% endfor %}
|
||||||
| {% endblock %}
|
| {% endblock %}
|
||||||
|
@@ -47,6 +47,7 @@ class AbstractShotTest(AbstractAttractTest):
|
|||||||
self.assertIsInstance(shot, pillarsdk.Node)
|
self.assertIsInstance(shot, pillarsdk.Node)
|
||||||
return shot
|
return shot
|
||||||
|
|
||||||
|
|
||||||
class ShotManagerTest(AbstractShotTest):
|
class ShotManagerTest(AbstractShotTest):
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_tasks_for_shot(self):
|
def test_tasks_for_shot(self):
|
||||||
@@ -124,6 +125,26 @@ class ShotManagerTest(AbstractShotTest):
|
|||||||
self.assertEqual(u'Shoot the Pad Thai', found['description'])
|
self.assertEqual(u'Shoot the Pad Thai', found['description'])
|
||||||
self.assertNotIn(u'notes', found['properties'])
|
self.assertNotIn(u'notes', found['properties'])
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_shot_summary(self):
|
||||||
|
shot1 = self.create_shot()
|
||||||
|
shot2 = self.create_shot()
|
||||||
|
shot3 = self.create_shot()
|
||||||
|
shot4 = self.create_shot()
|
||||||
|
|
||||||
|
with self.app.test_request_context():
|
||||||
|
# Log in as project admin user
|
||||||
|
pillar.auth.login_user(ctd.EXAMPLE_PROJECT_OWNER_ID)
|
||||||
|
|
||||||
|
self.mock_blenderid_validate_happy()
|
||||||
|
for shot, status in zip([shot1, shot2, shot3, shot4],
|
||||||
|
['todo', 'in_progress', 'todo', 'final']):
|
||||||
|
self.smngr.edit_shot(shot_id=shot['_id'],
|
||||||
|
status=status,
|
||||||
|
_etag=shot._etag)
|
||||||
|
|
||||||
|
# def shot_status_summary(self, project_id):
|
||||||
|
|
||||||
|
|
||||||
class NodeSetattrTest(unittest.TestCase):
|
class NodeSetattrTest(unittest.TestCase):
|
||||||
def test_simple(self):
|
def test_simple(self):
|
||||||
@@ -360,3 +381,35 @@ class RequiredAfterCreationTest(AbstractShotTest):
|
|||||||
|
|
||||||
# TODO: should test editing a shot as well, but I had issues with the PillarSDK
|
# TODO: should test editing a shot as well, but I had issues with the PillarSDK
|
||||||
# not handling deleting of properties.
|
# not handling deleting of properties.
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectSummaryTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from attract.shots import ProjectSummary
|
||||||
|
|
||||||
|
self.summ = ProjectSummary()
|
||||||
|
self.summ.count(u'todo')
|
||||||
|
self.summ.count(u'todo')
|
||||||
|
self.summ.count(u'in-progress')
|
||||||
|
self.summ.count(u'überhard')
|
||||||
|
self.summ.count(u'Æon Flux')
|
||||||
|
self.summ.count(u'Æon Flux')
|
||||||
|
self.summ.count(u'in-progress')
|
||||||
|
self.summ.count(u'todo')
|
||||||
|
|
||||||
|
def test_counting(self):
|
||||||
|
self.assertEqual(8, self.summ._total)
|
||||||
|
self.assertEqual(3, self.summ._counts[u'todo'])
|
||||||
|
self.assertEqual(2, self.summ._counts[u'Æon Flux'])
|
||||||
|
|
||||||
|
def test_percentages(self):
|
||||||
|
percs = list(self.summ.percentages())
|
||||||
|
|
||||||
|
self.assertEqual((u'in-progress', 25), percs[0])
|
||||||
|
self.assertEqual((u'todo', 38), percs[1])
|
||||||
|
self.assertEqual((u'Æon Flux', 25), percs[2])
|
||||||
|
|
||||||
|
# This should be rounded down, not rounded up, to ensure the sum of
|
||||||
|
# percentages is 100.
|
||||||
|
self.assertEqual((u'überhard', 12), percs[3])
|
||||||
|
Reference in New Issue
Block a user