Skip to content

Commit

Permalink
Merge pull request #1136 from 18F/nmb/analytics
Browse files Browse the repository at this point in the history
Utilization Analytics MVP
  • Loading branch information
Jkrzy authored Aug 5, 2020
2 parents d5d9482 + 149599a commit 7b3e5c6
Show file tree
Hide file tree
Showing 10 changed files with 317 additions and 150 deletions.
1 change: 1 addition & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ cg-django-uaa = "*"
"psycopg2-binary" = "*"
markdown = "*"
django-webtest = "~=1.9"
plotly = "*"

[dev-packages]
bandit = "*"
Expand Down
263 changes: 127 additions & 136 deletions Pipfile.lock

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,40 +1,40 @@
-i https://pypi.python.org/simple
beautifulsoup4==4.9.1
bleach==3.1.5
certifi==2020.4.5.2
certifi==2020.6.20
cfenv==0.5.3
cg-django-uaa==2.0.0
chardet==3.0.4
dj-database-url==0.5.0
django-webtest==1.9.7
django==2.2.13
django==2.2.14
djangorestframework-csv==2.1.0
djangorestframework==3.11.0
furl==2.1.0
gevent==20.6.0
gevent==20.6.2
greenlet==0.4.16; platform_python_implementation == 'CPython'
gunicorn==20.0.4
idna==2.9; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
importlib-metadata==1.6.1; python_version < '3.8'
idna==2.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
markdown==3.2.2
newrelic==5.14.0.142
newrelic==5.14.1.144
orderedmultidict==1.0.1
packaging==20.4; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
plotly==4.9.0
psycopg2-binary==2.8.5
pyjwt==1.7.1
pyparsing==2.4.7; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
pytz==2020.1
requests==2.23.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
requests==2.24.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
retrying==1.3.3
six==1.15.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
soupsieve==2.0.1; python_version >= '3.5'
sqlparse==0.3.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
unicodecsv==0.14.1
urllib3==1.25.9; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'
urllib3==1.25.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'
waitress==1.4.4; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
webencodings==0.5.1
webob==1.8.6; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
webtest==2.0.35
whitenoise==5.1.0
zipp==3.1.0; python_version >= '3.6'
zope.event==4.4
zope.interface==5.1.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
2 changes: 1 addition & 1 deletion tock/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def wait_for_db(max_attempts=15, seconds_between_attempts=1):
if testing:
import coverage
cov = coverage.coverage(
source=['tock', 'employees', 'projects', 'hours', 'api'],
source=['tock', 'employees', 'projects', 'hours', 'api', 'utilization'],
omit=['*/tests*', '*/migrations/*', '*/settings/*'],
)
cov.erase()
Expand Down
61 changes: 61 additions & 0 deletions tock/tock/static/js/plotly-1.54.7.min.js

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions tock/tock/templates/_navigation.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
<li class="usa-nav__submenu-item">
<a href="{% url 'utilization:GroupUtilizationView' %}">Utilization reports</a>
</li>
<li class="usa-nav__submenu-item">
<a href="{% url 'utilization:UtilizationAnalyticsView' %}">Analytics</a>
</li>
<li class="usa-nav__submenu-item">
<a href="{% url 'reports:ListReports' %}" class="usa-nav__link">
<span>Timecard reports</span>
Expand Down
14 changes: 14 additions & 0 deletions tock/tock/templates/utilization/utilization_analytics.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load static %}
{% block navigation %}
{% include "_navigation.html" %}
{% endblock %}

{% block content %}
<script src="{% static 'js/plotly-1.54.7.min.js' %}"></script>

<h2>Analytics</h2>

<h3>Overall Utilization</h3>
{{plot|safe}}
{% endblock %}
12 changes: 12 additions & 0 deletions tock/utilization/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,15 @@ def test_user_detail_with_utilization(self):
)

self.assertEqual(response.status_code, 200)


class TestAnalyticsView(TestGroupUtilizationView):

def test_analytics_view(self):

response = self.app.get(
url=reverse('utilization:UtilizationAnalyticsView'),
user=self.user
)

self.assertEqual(response.status_code, 200)
7 changes: 4 additions & 3 deletions tock/utilization/urls.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from django.urls import path

from .views import GroupUtilizationView
from .views import GroupUtilizationView, UtilizationAnalyticsView

app_name = 'utilization'
urlpatterns = [
path('', GroupUtilizationView.as_view(), name='GroupUtilizationView')
]
path('', GroupUtilizationView.as_view(), name='GroupUtilizationView'),
path('analytics', UtilizationAnalyticsView.as_view(), name='UtilizationAnalyticsView')
]
86 changes: 85 additions & 1 deletion tock/utilization/views.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
from datetime import date

from django.apps import apps
from django.contrib.auth import get_user_model
from django.views.generic import ListView
from django.db.models import Sum
from django.views.generic import ListView, TemplateView
from hours.models import ReportingPeriod
from organizations.models import Unit
from tock.utils import PermissionMixin

import plotly.graph_objects as go
from plotly.offline import plot

from .org import org_billing_context
from .unit import unit_billing_context

Expand Down Expand Up @@ -50,3 +57,80 @@ def get_context_data(self, **kwargs):
context.update({'org_totals': org_billing_context()}
)
return context


def _plot_utilization(dates, billable, nonbillable):
"""Make a stacked area plot of billable and nonbillable hours.
dates, billable, and nonbillable should be sequences with the same length
"""
fig = go.Figure()

fig.add_trace(go.Scatter(
x=dates,
y=billable,
line_shape="hv",
mode="lines",
stackgroup="one",
name="Billable",
))
fig.add_trace(go.Scatter(
x=dates,
y=nonbillable,
line_shape="hv",
mode="lines",
stackgroup="one",
name="Non-Billable",
))

fig.update_layout(
# autosize=False,
# width=900,
# height=500,
xaxis=dict(autorange=True),
yaxis=dict(autorange=True),
xaxis_title="Reporting Period Start Date",
yaxis_title="Hours",
title="Total Hours recorded vs. Time",
)

plot_div = plot(fig, output_type='div', include_plotlyjs=False)
return plot_div


def _utilization_data(start_date, end_date):
Timecard = apps.get_model("hours", "Timecard")
data = (Timecard.objects.filter(reporting_period__start_date__gte=start_date,
reporting_period__end_date__lte=end_date,
submitted=True,
)
.values("reporting_period__start_date")
.annotate(billable=Sum("billable_hours"),
nonbillable=Sum("non_billable_hours"))
.order_by("reporting_period__start_date")
)
dates = [item["reporting_period__start_date"] for item in data]
billable_hours = [item["billable"] for item in data]
nonbillable_hours = [item["nonbillable"] for item in data]
return dates, billable_hours, nonbillable_hours


def utilization_plot(start_date, end_date):
"""Fetch data and make a plot for total utilization."""
return _plot_utilization(*_utilization_data(start_date, end_date))


class UtilizationAnalyticsView(PermissionMixin, TemplateView):
template_name = 'utilization/utilization_analytics.html'

def get_context_data(self, **kwargs):
context = super(UtilizationAnalyticsView, self).get_context_data(**kwargs)

# use a date before Tock as the default start_date
start_date = self.request.GET.get("start", "2000-01-01")
end_date = self.request.GET.get("end", date.today().isoformat())

# add the plot div to the context
context.update({"plot": utilization_plot(start_date, end_date)})

return context

0 comments on commit 7b3e5c6

Please sign in to comment.