From 8d76c60686aabccea168d1715f4b90daddf52684 Mon Sep 17 00:00:00 2001 From: Jonathan Tsai Date: Mon, 13 Dec 2021 18:16:22 -0800 Subject: [PATCH 1/6] - adds `CUSTOM_STATIC_DIR` and `CUSTOM_STYLESHEETS` served by `{{ url_for('custom_static', filename=filename) }}` (#9) --- phablytics/settings.py | 6 +++++- phablytics/web/app.py | 11 ++++++++++- phablytics/web/templates/fragments/css/common.html | 4 ++++ phablytics/web/utils.py | 3 +++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/phablytics/settings.py b/phablytics/settings.py index 5837ae3..5685c28 100644 --- a/phablytics/settings.py +++ b/phablytics/settings.py @@ -25,7 +25,6 @@ # Reports - @dataclass class ReportConfig: name: str @@ -123,6 +122,11 @@ class ReportConfig: ), ] +# Web UI + +CUSTOM_STATIC_DIR = None + +CUSTOM_STYLESHEETS = [] ## # Import Local Settings if `local_settings.py` exists in CWD diff --git a/phablytics/web/app.py b/phablytics/web/app.py index 21f1f2f..acbeccb 100644 --- a/phablytics/web/app.py +++ b/phablytics/web/app.py @@ -4,10 +4,14 @@ import uuid # Third Party (PyPI) Imports -from flask import Flask +from flask import ( + Flask, + send_from_directory, +) from werkzeug.routing import BaseConverter # Phablytics Imports +from phablytics.settings import CUSTOM_STATIC_DIR from phablytics.web.explore import explore_page from phablytics.web.help import help_page from phablytics.web.home import home_page @@ -56,3 +60,8 @@ def __init__(self, url_map, *items): @application.errorhandler(404) def page_not_found(e): return _r('404.html') + + +@application.route('/custom_static/') +def custom_static(filename): + return send_from_directory(CUSTOM_STATIC_DIR, filename) diff --git a/phablytics/web/templates/fragments/css/common.html b/phablytics/web/templates/fragments/css/common.html index 629df85..919b3ae 100644 --- a/phablytics/web/templates/fragments/css/common.html +++ b/phablytics/web/templates/fragments/css/common.html @@ -5,3 +5,7 @@ + +{% for custom_stylesheet in custom_stylesheets %} + +{% endfor %} diff --git a/phablytics/web/utils.py b/phablytics/web/utils.py index d4a2452..debda15 100644 --- a/phablytics/web/utils.py +++ b/phablytics/web/utils.py @@ -14,6 +14,7 @@ from phablytics.constants import GITHUB_URL from phablytics.settings import ( ADMIN_USERNAME, + CUSTOM_STYLESHEETS, PHABRICATOR_INSTANCE_BASE_URL, ) from phablytics.web.constants import ( @@ -48,6 +49,8 @@ def get_context_data(): page_title = SITE_NAME context_data = { + # customizations + 'custom_stylesheets': CUSTOM_STYLESHEETS, # page meta 'nav_links': nav_links, 'breadcrumbs': breadcrumbs, From 3532f84b15c30dee4c2efa9c2e2bc50a7a5611e3 Mon Sep 17 00:00:00 2001 From: Jonathan Tsai Date: Mon, 13 Dec 2021 18:49:04 -0800 Subject: [PATCH 2/6] Minor improvements to metrics explorer (#10) - consistently sets default value for interval to `week` (was: `quarter` (form) or `month` (UI)) - converts TaskMetric from `namedtuple` base class to `dataclass` --- phablytics/metrics/constants.py | 14 +++++++++++ phablytics/metrics/metrics.py | 24 ++++++++++--------- phablytics/web/metrics/forms.py | 16 +++++-------- phablytics/web/templates/explore/explore.html | 4 ++-- 4 files changed, 35 insertions(+), 23 deletions(-) diff --git a/phablytics/metrics/constants.py b/phablytics/metrics/constants.py index de464c4..4b6edfc 100644 --- a/phablytics/metrics/constants.py +++ b/phablytics/metrics/constants.py @@ -1,2 +1,16 @@ DATE_FORMAT_YMD = '%Y-%m-%d' DATE_FORMAT_MDY_SHORT = '%b %d, %Y' + +INTERVAL_OPTIONS = [ + 'week', + 'month', + 'quarter', +] +DEFAULT_INTERVAL_OPTION = 'week' + +INTERVAL_DAYS_MAP = { + 'week': 7, + 'month': 30, + 'quarter': 90, +} +DEFAULT_INTERVAL_DAYS = INTERVAL_DAYS_MAP[DEFAULT_INTERVAL_OPTION] diff --git a/phablytics/metrics/metrics.py b/phablytics/metrics/metrics.py index 3855b64..e6919d2 100644 --- a/phablytics/metrics/metrics.py +++ b/phablytics/metrics/metrics.py @@ -1,7 +1,7 @@ # Python Standard Library Imports import datetime import pprint -from collections import namedtuple +from dataclasses import dataclass # Third Party (PyPI) Imports import numpy @@ -11,7 +11,11 @@ MANIPHEST_STATUSES_CLOSED, MANIPHEST_STATUSES_OPEN, ) -from phablytics.metrics.constants import DATE_FORMAT_MDY_SHORT +from phablytics.metrics.constants import ( + DATE_FORMAT_MDY_SHORT, + DEFAULT_INTERVAL_DAYS, + INTERVAL_DAYS_MAP, +) from phablytics.metrics.stats import TaskMetricsStats from phablytics.utils import ( get_bulk_projects_by_name, @@ -41,10 +45,16 @@ def description(cls): return desc +@dataclass class TaskMetric( - namedtuple('TaskMetric', 'period_name,period_start,period_end,tasks_created,tasks_closed'), metaclass=MetricMeta ): + period_name: str + period_start: datetime.datetime + period_end: datetime.datetime + tasks_created: list + tasks_closed: list + """Tracks tasks opened vs closed over time. """ def as_dict(self): @@ -158,13 +168,6 @@ class StoryMetric(TaskMetric): TaskMetric, ] -INTERVAL_DAYS_MAP = { - 'week': 7, - 'month': 30, - 'quarter': 90, -} -DEFAULT_INTERVAL_DAYS = INTERVAL_DAYS_MAP['week'] - class Metrics: def _retrieve_task_metrics( @@ -187,7 +190,6 @@ def _retrieve_task_metrics( raise Exception('period_start must be before period_end') interval_days = INTERVAL_DAYS_MAP.get(interval, DEFAULT_INTERVAL_DAYS) - if team: project = get_project_by_name(team, include_members=True) team_member_phids = project.member_phids diff --git a/phablytics/web/metrics/forms.py b/phablytics/web/metrics/forms.py index 946aacc..b853eda 100644 --- a/phablytics/web/metrics/forms.py +++ b/phablytics/web/metrics/forms.py @@ -12,7 +12,11 @@ ) # Phablytics Imports -from phablytics.metrics.constants import DATE_FORMAT_YMD +from phablytics.metrics.constants import ( + DATE_FORMAT_YMD, + DEFAULT_INTERVAL_OPTION, + INTERVAL_OPTIONS, +) from phablytics.settings import PROJECT_TEAM_NAMES from phablytics.utils import ( end_of_month, @@ -24,20 +28,11 @@ from phablytics.web.utils import format_choices -INTERVAL_OPTIONS = [ - 'week', - 'month', - 'quarter', -] -DEFAULT_INTERVAL_OPTION = 'month' - - def get_filter_params(): interval = request.args.get('interval', DEFAULT_INTERVAL_OPTION) today = datetime.date.today() - if interval == 'month': period_end_default = end_of_month(today) period_start_default = start_of_month(period_end_default - datetime.timedelta(days=360)) @@ -45,6 +40,7 @@ def get_filter_params(): period_end_default = end_of_quarter(today) period_start_default = start_of_quarter(period_end_default - datetime.timedelta(days=360)) else: + # `interval == 'week'` or unspecified last_month = today - datetime.timedelta(days=31) tomorrow = today + datetime.timedelta(days=1) diff --git a/phablytics/web/templates/explore/explore.html b/phablytics/web/templates/explore/explore.html index 49167f6..2a4ad66 100644 --- a/phablytics/web/templates/explore/explore.html +++ b/phablytics/web/templates/explore/explore.html @@ -17,7 +17,7 @@
{{ segment.name}}
{# Subsegments #} {% for subsegment in segment.segments %}
  • - + {{ subsegment.name }}
  • @@ -26,7 +26,7 @@
    {{ segment.name}}
    {# Regular Projects #} {% for project in segment.projects %}
  • - + {{ project.name }}
  • From 05a054a21738fa9f9b9977c61cc782b28ee8dced Mon Sep 17 00:00:00 2001 From: Jonathan Tsai Date: Mon, 13 Dec 2021 19:15:50 -0800 Subject: [PATCH 3/6] Fixes `start_of_month` by simply using `1`, instead of `calendar.monthrange[0]`, (#11) which was erroneously setting it to the [week-day of the first day of the month](https://docs.python.org/3/library/calendar.html#calendar.monthrange) --- phablytics/utils/dt.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/phablytics/utils/dt.py b/phablytics/utils/dt.py index 8bd1238..ccf6abd 100644 --- a/phablytics/utils/dt.py +++ b/phablytics/utils/dt.py @@ -37,11 +37,15 @@ def end_of_quarter(dt): return end_of_quarter -def start_of_month(dt): +def start_of_month(dt: datetime.datetime): + """Returns the first day of the month for `dt`. + """ y, m, d = dt.year, dt.month, dt.day - return datetime.date(y, m, calendar.monthrange(y, m)[0]) + return datetime.date(y, m, 1) -def end_of_month(dt): +def end_of_month(dt: datetime.datetime): + """Returns the last day of the month for `dt`. + """ y, m, d = dt.year, dt.month, dt.day return datetime.date(y, m, calendar.monthrange(y, m)[1]) From 076384330387408e26563790d595dd0007164356 Mon Sep 17 00:00:00 2001 From: Jonathan Tsai Date: Mon, 13 Dec 2021 19:47:18 -0800 Subject: [PATCH 4/6] Fixes metric aggregation by customer, service, and owner (#12) - Input to `itertools.groupby` needs to be already sorted by the grouping key (https://docs.python.org/3/library/itertools.html#itertools.groupby) - adds type hints to several functions --- phablytics/metrics/metrics.py | 79 +++++++++++++++++++++++++---------- phablytics/metrics/stats.py | 49 ++++++++++++++-------- 2 files changed, 88 insertions(+), 40 deletions(-) diff --git a/phablytics/metrics/metrics.py b/phablytics/metrics/metrics.py index e6919d2..8730283 100644 --- a/phablytics/metrics/metrics.py +++ b/phablytics/metrics/metrics.py @@ -75,22 +75,22 @@ def as_dict(self): # Raw Metrics @property - def num_created(self): + def num_created(self) -> int: num_created = len(self.tasks_created) return num_created @property - def num_closed(self): + def num_closed(self) -> int: num_closed = len(self.tasks_closed) return num_closed @property - def points_added(self): + def points_added(self) -> int: points = sum([task.points for task in self.tasks_created]) return points @property - def points_completed(self): + def points_completed(self) -> int: points = sum([task.points for task in self.tasks_closed]) return points @@ -98,7 +98,7 @@ def points_completed(self): # Ratio Metrics @property - def ratio(self): + def ratio(self) -> float: try: ratio = self.num_closed / self.num_created except ZeroDivisionError: @@ -106,12 +106,12 @@ def ratio(self): return ratio @property - def num_created_per_total(self): + def num_created_per_total(self) -> float: rate = 1.0 * self.num_created / max(self.num_closed + self.num_created, 1) return rate @property - def mean_days_to_resolution(self): + def mean_days_to_resolution(self) -> float: values = [task.days_to_resolution for task in self.tasks_closed] mean_days = numpy.mean(values) if values else 0 return mean_days @@ -120,18 +120,18 @@ def mean_days_to_resolution(self): # Normalized Metrics @property - def days_to_resolution_per_point(self): + def days_to_resolution_per_point(self) -> float: days = sum([task.days_to_resolution for task in self.tasks_closed]) days_per_story_point = 1.0 * days / max(self.points_completed, 1) return days_per_story_point @property - def points_per_task(self): + def points_per_task(self) -> float: points_per_task = 1.0 * self.points_completed / max(self.num_closed, 1) return points_per_task @property - def tasks_per_point(self): + def tasks_per_point(self) -> float: tasks_per_point = 1.0 * self.num_closed / max(self.points_completed, 1) return tasks_per_point @@ -172,13 +172,13 @@ class StoryMetric(TaskMetric): class Metrics: def _retrieve_task_metrics( self, - interval, - period_start, - period_end, - task_subtypes, - team=None, - customer=None, - projects=None, + interval: str, + period_start: datetime.datetime, + period_end: datetime.datetime, + task_subtypes: list[str], + team: str=None, + customer: str=None, + projects: list=None, *args, **kwargs ): @@ -250,7 +250,14 @@ def _retrieve_task_metrics( return stats - def alltasks(self, interval, period_start, period_end, *args, **kwargs): + def alltasks( + self, + interval: str, + period_start: datetime.datetime, + period_end: datetime.datetime, + *args, + **kwargs + ): """Returns the rate of tasks opened/closed over a period """ task_subtypes = [ @@ -269,7 +276,14 @@ def alltasks(self, interval, period_start, period_end, *args, **kwargs): ) return stats - def bugs(self, interval, period_start, period_end, *args, **kwargs): + def bugs( + self, + interval: str, + period_start: datetime.datetime, + period_end: datetime.datetime, + *args, + **kwargs + ): """Returns the rate of bugs opened/closed over a period """ task_subtypes = [ @@ -285,7 +299,14 @@ def bugs(self, interval, period_start, period_end, *args, **kwargs): ) return stats - def features(self, interval, period_start, period_end, *args, **kwargs): + def features( + self, + interval, + period_start, + period_end, + *args, + **kwargs + ): """Returns the rate of features opened/closed over a period """ task_subtypes = [ @@ -302,7 +323,14 @@ def features(self, interval, period_start, period_end, *args, **kwargs): ) return stats - def stories(self, interval, period_start, period_end, *args, **kwargs): + def stories( + self, + interval: str, + period_start: datetime.datetime, + period_end: datetime.datetime, + *args, + **kwargs + ): """Returns the rate of stories opened/closed over a period """ task_subtypes = [ @@ -319,7 +347,14 @@ def stories(self, interval, period_start, period_end, *args, **kwargs): ) return stats - def tasks(self, interval, period_start, period_end, *args, **kwargs): + def tasks( + self, + interval: str, + period_start: datetime.datetime, + period_end: datetime.datetime, + *args, + **kwargs + ): """Returns the rate of tasks opened/closed over a period """ task_subtypes = [ diff --git a/phablytics/metrics/stats.py b/phablytics/metrics/stats.py index 108b7fc..18926e3 100644 --- a/phablytics/metrics/stats.py +++ b/phablytics/metrics/stats.py @@ -1,4 +1,5 @@ # Python Standard Library Imports +import datetime import itertools import json import random @@ -93,17 +94,24 @@ def _get_aggregated_stats(self): aggregated_stats = AggregatedTaskMetricsStats( metric_cls, - period_start, - period_end, - tasks_created, - tasks_closed + period_start: datetime.datetime, + period_end: datetime.datetime, + tasks_created: list, + tasks_closed: list ) return aggregated_stats class AggregatedTaskMetricsStats: - def __init__(self, metric_cls, period_start, period_end, tasks_created, tasks_closed): + def __init__( + self, + metric_cls, + period_start: datetime.datetime, + period_end: datetime.datetime, + tasks_created: list, + tasks_closed: list + ): self.metric_cls = metric_cls self.period_start = period_start self.period_end = period_end @@ -127,7 +135,14 @@ def _build_metrics(self): self._build_segments() - def _build_metric(self, name, period_start, period_end, tasks_created, tasks_closed): + def _build_metric( + self, + name: str, + period_start: datetime.datetime, + period_end: datetime.datetime, + tasks_created: list, + tasks_closed: list + ): metric = self.metric_cls( period_name=name, period_start=period_start, @@ -182,13 +197,12 @@ def _build_customer_segment(self): 'name': 'Tasks by Customer', } + _grouping_key = lambda task: task.customer_name or 'General' + tasks_closed_sorted_by_customer = sorted(self.tasks_closed, key=_grouping_key) tasks_by_customers = { customer: list(tasks) for customer, tasks - in itertools.groupby( - self.tasks_closed, - lambda task: task.customer_name or 'General' - ) + in itertools.groupby(tasks_closed_sorted_by_customer, _grouping_key) } metrics = [ @@ -247,13 +261,12 @@ def _build_user_segment(self): 'name': 'Tasks by Owner/Author', } + _grouping_key = lambda task: task.owner_phid or task.author_phid + tasks_closed_sorted_by_owners = sorted(self.tasks_closed, key=_grouping_key) tasks_closed_by_owners = { user_phid: list(tasks) for user_phid, tasks - in itertools.groupby( - self.tasks_closed, - lambda task: task.owner_phid - ) + in itertools.groupby(tasks_closed_sorted_by_owners, _grouping_key) if user_phid } @@ -315,13 +328,13 @@ def _build_service_segment(self): 'name': 'Tasks by Service', } + _grouping_key = lambda task: task.service_name or 'General' + + tasks_closed_sorted_by_services = sorted(self.tasks_closed, key=_grouping_key) tasks_by_services = { service: list(tasks) for service, tasks - in itertools.groupby( - self.tasks_closed, - lambda task: task.service_name or 'General' - ) + in itertools.groupby(tasks_closed_sorted_by_services, _grouping_key) } metrics = [ From b6beba2cdfca1e9e781716beb6d17e5359c2166f Mon Sep 17 00:00:00 2001 From: Jonathan Tsai Date: Tue, 14 Dec 2021 03:56:11 +0000 Subject: [PATCH 5/6] updates CHANGELOG between v2.1.2 - v3.0.1 --- CHANGELOG.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a92c8b..9b6ddec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,61 @@ # CHANGELOG +## v3.0.1 (2021-04-06) +-Include all open Maniphest statuses for upcoming tasks due report + - awaitingbusiness + - inprogress + - open + - stalled + +## v3.0.0 (2020-12-22) +- added Explore page and blueprint +- adds mechanism to retrieve a list of Customers (special Project tag) +- adds the ability to filter task metrics by Customer +- styling for applied filters string +- formatting for task metrics +- adds custom segments (including nested segments) for projects on explore page +- filter by bulk project names +- add hyperlinks with quarter interval to explore +- slightly improved interval calculation +- improved section headings indicating interval +- adds aggregated metrics, normalized metrics and rate metrics +- tasks by project_phids query use AND, so need to make multiple queries +- display 2 decimal points +- adds AggregatedTaskMetricsStats as companion to TaskMetricsStats +- segment tasks by customer, users, and service +- move/refactor utils.py and constants.py into module with submodules +- add checks for null projects + +## v2.7.0 (2020-11-19) +- adds statistical analysis + +## v2.6.0 (2020-11-19) +- Version bump only + +## v2.5.0 (2020-11-18) +- adds Stories, Features, Tasks in addition to Bugs as metric types + +## v2.4.0 (2020-11-18) +- adds filters to web metrics +- adds the ability to select interval: week/month +- adds the ability to set custom start and end period + +## v2.3.0 (2020-11-18) +- Adds breadcrumbs to website + +## v2.2.1 (2020-11-04) +- Adds report setting: non_group_reviewer_acceptance_threshold +- Allows requiring a minimum number of acceptances by non-group reviewers for GroupReviewStatusReport + +## v2.2.0 (2020-10-27) +- Adds GroupReviewStatusReport +- adds web link for RevisionStatusReport and GroupsReviewStatusReport +- convert REPORTS config from dict to list + +## v2.1.2 (2020-10-21) +- update Maniphest open, closed statuses +- fix BugMetric namedtuple name + ## v2.1.1 (2020-08-25) - Use accordion component to show/collapse bugs From 140497bfdc26ac8f90f723664e0beb4c6ed2f404 Mon Sep 17 00:00:00 2001 From: Jonathan Tsai Date: Tue, 14 Dec 2021 03:59:24 +0000 Subject: [PATCH 6/6] v3.1.0 --- CHANGELOG.md | 8 +++++++- VERSION | 2 +- phablytics/__init__.py | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b6ddec..7333f06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,13 @@ # CHANGELOG +## v3.1.0 (2021-12-13) +- Fixes metric aggregation by customer, service, and owner (#12) +- Fixes `start_of_month` by simply using `1`, instead of `calendar.monthrange[0]`, (#11) +- Minor improvements to metrics explorer (#10) +- adds `CUSTOM_STATIC_DIR` and `CUSTOM_STYLESHEETS` served by `{{ url_for('custom_static', filename=filename) }}` (#9) + ## v3.0.1 (2021-04-06) --Include all open Maniphest statuses for upcoming tasks due report +- Include all open Maniphest statuses for upcoming tasks due report - awaitingbusiness - inprogress - open diff --git a/VERSION b/VERSION index cb2b00e..fd2a018 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.1 +3.1.0 diff --git a/phablytics/__init__.py b/phablytics/__init__.py index b7a5531..7f5601d 100644 --- a/phablytics/__init__.py +++ b/phablytics/__init__.py @@ -1 +1 @@ -__version__ = '3.0.1' +__version__ = '3.1.0'