From d9a41c11b7023db1d4c2ef66f9719781c70bfd8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Wed, 5 Oct 2016 14:42:01 +0200 Subject: [PATCH] Added shot summary per project on /attract --- attract/__init__.py | 22 ++++++++++++ attract/routes.py | 10 +++++- attract/shots/__init__.py | 61 ++++++++++++++++++++++++++++++++ attract/shots/routes.py | 10 +----- src/styles/_base.sass | 6 ++++ src/templates/attract/index.jade | 20 +++++++---- tests/test_shots.py | 53 +++++++++++++++++++++++++++ 7 files changed, 165 insertions(+), 17 deletions(-) diff --git a/attract/__init__.py b/attract/__init__.py index b7b4fd8..8737fe0 100644 --- a/attract/__init__.py +++ b/attract/__init__.py @@ -2,6 +2,7 @@ import logging import attract.shots import flask +import flask_login from werkzeug.local import LocalProxy from pillar.extension import PillarExtension @@ -86,6 +87,27 @@ class AttractExtension(PillarExtension): tasks.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(): """Returns the Attract extension of the current application.""" diff --git a/attract/routes.py b/attract/routes.py index dc7487c..ff6c457 100644 --- a/attract/routes.py +++ b/attract/routes.py @@ -19,11 +19,19 @@ def index(): user = flask_login.current_user if user.is_authenticated: tasks = current_attract.task_manager.tasks_for_user(user.objectid) + else: 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', - tasks=tasks) + tasks=tasks, + projs_with_summaries=projs_with_summaries) def error_project_not_setup_for_attract(): diff --git a/attract/shots/__init__.py b/attract/shots/__init__.py index d226611..41d5d52 100644 --- a/attract/shots/__init__.py +++ b/attract/shots/__init__.py @@ -35,6 +35,38 @@ VALID_PATCH_OPERATIONS = { 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 class ShotManager(object): _log = attrs_extra.log('%s.ShotManager' % __name__) @@ -135,6 +167,35 @@ class ShotManager(object): 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): """Sets a node property by dotted key. diff --git a/attract/shots/routes.py b/attract/shots/routes.py index d5dbe17..0c38cae 100644 --- a/attract/shots/routes.py +++ b/attract/shots/routes.py @@ -19,15 +19,7 @@ log = logging.getLogger(__name__) @blueprint.route('/') def index(): - 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) - + projects = current_attract.attract_projects() return render_template('attract/shots/index.html', projects=projects['_items']) diff --git a/src/styles/_base.sass b/src/styles/_base.sass index e1183f1..8e00ccc 100644 --- a/src/styles/_base.sass +++ b/src/styles/_base.sass @@ -197,3 +197,9 @@ select.input-transparent .btn:active box-shadow: none + +.bg-status + @include status-color-property(background-color, '', 'dark') + +.fg-status + @include status-color-property(color, '', 'dark') diff --git a/src/templates/attract/index.jade b/src/templates/attract/index.jade index e0e4bc2..2f7f02c 100644 --- a/src/templates/attract/index.jade +++ b/src/templates/attract/index.jade @@ -24,13 +24,19 @@ 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-bar.progress-bar-success(role="progressbar", style="width:50%") - | Final - .progress-bar.progress-bar-info(role="progressbar", style="width:30%") - | Review - .progress-bar.progress-bar-danger(role="progressbar", style="width:20%") - | ToDo + | {% for status, percentage in summary.percentages() %} + .progress-bar.bg-status( + class="status-{{status}}", + title="{{ status | undertitle }}", + role="progressbar", + style="width:{{percentage}}%") + | {{ status | undertitle }} + | {% endfor %} + | {% endfor %} | {% endblock %} diff --git a/tests/test_shots.py b/tests/test_shots.py index f6f56a5..ab5c2e5 100644 --- a/tests/test_shots.py +++ b/tests/test_shots.py @@ -47,6 +47,7 @@ class AbstractShotTest(AbstractAttractTest): self.assertIsInstance(shot, pillarsdk.Node) return shot + class ShotManagerTest(AbstractShotTest): @responses.activate def test_tasks_for_shot(self): @@ -124,6 +125,26 @@ class ShotManagerTest(AbstractShotTest): self.assertEqual(u'Shoot the Pad Thai', found['description']) 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): 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 # 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])