From 26e8a1f689da38878217fb33b9658eec0e64d364 Mon Sep 17 00:00:00 2001 From: Marco H Date: Sat, 16 Apr 2022 17:05:44 +0200 Subject: [PATCH] New statistic graph: Diaper change intervals Fix formatting Fix comment Remove debug Remove debug Change naming of variable Add new statistics graph: Feeding intervals Fix naming Add simple HTTP status code tests for new graphs Remove number of feedings from graph Remove obsolete yaxis2 Fix calculation of intervals and change scale to hours instead of minutes --- reports/graphs/__init__.py | 2 + reports/graphs/diaperchange_intervals.py | 93 +++++++++++++++++++ reports/graphs/feeding_intervals.py | 58 ++++++++++++ .../reports/diaperchange_intervals.html | 9 ++ .../templates/reports/feeding_intervals.html | 9 ++ reports/templates/reports/report_list.html | 2 + reports/tests/tests_views.py | 4 + reports/urls.py | 10 ++ reports/views.py | 39 +++++++- 9 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 reports/graphs/diaperchange_intervals.py create mode 100644 reports/graphs/feeding_intervals.py create mode 100644 reports/templates/reports/diaperchange_intervals.html create mode 100644 reports/templates/reports/feeding_intervals.html diff --git a/reports/graphs/__init__.py b/reports/graphs/__init__.py index e9531fbc..da3d2159 100644 --- a/reports/graphs/__init__.py +++ b/reports/graphs/__init__.py @@ -2,8 +2,10 @@ from .bmi_change import bmi_change # NOQA from .diaperchange_amounts import diaperchange_amounts # NOQA from .diaperchange_lifetimes import diaperchange_lifetimes # NOQA from .diaperchange_types import diaperchange_types # NOQA +from .diaperchange_intervals import diaperchange_intervals # NOQA from .feeding_amounts import feeding_amounts # NOQA from .feeding_duration import feeding_duration # NOQA +from .feeding_intervals import feeding_intervals # NOQA from .head_circumference_change import head_circumference_change # NOQA from .height_change import height_change # NOQA from .pumping_amounts import pumping_amounts # NOQA diff --git a/reports/graphs/diaperchange_intervals.py b/reports/graphs/diaperchange_intervals.py new file mode 100644 index 00000000..935e822e --- /dev/null +++ b/reports/graphs/diaperchange_intervals.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +from django.db.models import Count, Case, When +from django.db.models.functions import TruncDate +from django.utils.translation import gettext as _ +from django.utils.translation import get_language + +import plotly.offline as plotly +import plotly.graph_objs as go + +from core.utils import duration_parts + +from reports import utils + + +def diaperchange_intervals(changes): + """ + Create a graph showing intervals of diaper changes. + :param changes: a QuerySet of Diaper Change instances. + :returns: a tuple of the the graph's html and javascript. + """ + + changes = changes.order_by("time") + intervals = [] + intervals_solid = [] + intervals_wet = [] + last_change = changes.first() + for change in changes[1:]: + interval = change.time - last_change.time + if interval.total_seconds() > 0: + intervals.append(interval) + if change.solid: + intervals_solid.append(interval) + if change.wet: + intervals_wet.append(interval) + last_change = change + + trace_solid = go.Scatter( + name=_("Solid"), + line=dict(shape="spline"), + x=list(changes.values_list("time", flat=True)), + y=[i.total_seconds() / 3600 for i in intervals_solid], + hoverinfo="text", + text=[_duration_string_hms(i) for i in intervals_solid], + ) + + trace_wet = go.Scatter( + name=_("Wet"), + line=dict(shape="spline"), + x=list(changes.values_list("time", flat=True)), + y=[i.total_seconds() / 3600 for i in intervals_wet], + hoverinfo="text", + text=[_duration_string_hms(i) for i in intervals_wet], + ) + + trace_total = go.Scatter( + name=_("Total"), + line=dict(shape="spline"), + x=list(changes.values_list("time", flat=True)), + y=[i.total_seconds() / 3600 for i in intervals], + hoverinfo="text", + text=[_duration_string_hms(i) for i in intervals], + ) + + layout_args = utils.default_graph_layout_options() + layout_args["barmode"] = "stack" + layout_args["title"] = _("Diaper Change Intervals") + layout_args["xaxis"]["title"] = _("Date") + layout_args["xaxis"]["rangeselector"] = utils.rangeselector_date() + layout_args["yaxis"]["title"] = _("Interval (hours)") + + fig = go.Figure( + { + "data": [trace_solid, trace_wet, trace_total], + "layout": go.Layout(**layout_args), + } + ) + output = plotly.plot( + fig, + output_type="div", + include_plotlyjs=False, + config={"locale": get_language()}, + ) + return utils.split_graph_output(output) + + +def _duration_string_hms(duration): + """ + Format a duration string with hours, minutes and seconds. This is + intended to fit better in smaller spaces on a graph. + :returns: a string of the form Xm. + """ + h, m, s = duration_parts(duration) + return "{}h{}m{}s".format(h, m, s) diff --git a/reports/graphs/feeding_intervals.py b/reports/graphs/feeding_intervals.py new file mode 100644 index 00000000..35db96a9 --- /dev/null +++ b/reports/graphs/feeding_intervals.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +from django.db.models import Count, Sum +from django.db.models.functions import TruncDate +from django.utils.translation import gettext as _ + +import plotly.offline as plotly +import plotly.graph_objs as go + +from core.utils import duration_parts + +from reports import utils + + +def feeding_intervals(instances): + """ + Create a graph showing intervals of feeding instances over time. + + :param instances: a QuerySet of Feeding instances. + :returns: a tuple of the the graph's html and javascript. + """ + totals = instances.annotate(count=Count("id")).order_by("start") + + intervals = [] + last_feeding = totals.first() + for feeding in totals[1:]: + interval = feeding.start - last_feeding.start + if interval.total_seconds() > 0: + intervals.append(interval) + last_feeding = feeding + + trace_avg = go.Scatter( + name=_("Interval"), + line=dict(shape="spline"), + x=list(totals.values_list("start", flat=True)), + y=[i.total_seconds() / 3600 for i in intervals], + hoverinfo="text", + text=[_duration_string_hms(i) for i in intervals], + ) + + layout_args = utils.default_graph_layout_options() + layout_args["title"] = _("Feeding intervals") + layout_args["xaxis"]["title"] = _("Date") + layout_args["xaxis"]["rangeselector"] = utils.rangeselector_date() + layout_args["yaxis"]["title"] = _("Feeding interval (hours)") + + fig = go.Figure({"data": [trace_avg], "layout": go.Layout(**layout_args)}) + output = plotly.plot(fig, output_type="div", include_plotlyjs=False) + return utils.split_graph_output(output) + + +def _duration_string_hms(duration): + """ + Format a duration string with hours, minutes and seconds. This is + intended to fit better in smaller spaces on a graph. + :returns: a string of the form Xm. + """ + h, m, s = duration_parts(duration) + return "{}h{}m{}s".format(h, m, s) diff --git a/reports/templates/reports/diaperchange_intervals.html b/reports/templates/reports/diaperchange_intervals.html new file mode 100644 index 00000000..351c5ea1 --- /dev/null +++ b/reports/templates/reports/diaperchange_intervals.html @@ -0,0 +1,9 @@ +{% extends 'reports/report_base.html' %} +{% load i18n %} + +{% block title %}{% trans "Diaper Change Intervals" %} - {{ object }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} diff --git a/reports/templates/reports/feeding_intervals.html b/reports/templates/reports/feeding_intervals.html new file mode 100644 index 00000000..127631aa --- /dev/null +++ b/reports/templates/reports/feeding_intervals.html @@ -0,0 +1,9 @@ +{% extends 'reports/report_base.html' %} +{% load i18n %} + +{% block title %}{% trans "Feeding Intervals" %} - {{ object }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} diff --git a/reports/templates/reports/report_list.html b/reports/templates/reports/report_list.html index f09a3f93..45f251a5 100644 --- a/reports/templates/reports/report_list.html +++ b/reports/templates/reports/report_list.html @@ -20,10 +20,12 @@ {% trans "Diaper Change Amounts" %} {% trans "Diaper Change Types" %} {% trans "Diaper Lifetimes" %} + {% trans "Diaper Intervals" %} {% trans "Feeding Amounts" %} {% trans "Feeding Durations (Average)" %} {% trans "Head Circumference" %} {% trans "Height" %} + {% trans "Feeding Intervals" %} {% trans "Pumping Amounts" %} {% trans "Sleep Pattern" %} {% trans "Sleep Totals" %} diff --git a/reports/tests/tests_views.py b/reports/tests/tests_views.py index b2fdec7d..4d460870 100644 --- a/reports/tests/tests_views.py +++ b/reports/tests/tests_views.py @@ -46,11 +46,15 @@ class ViewsTestCase(TestCase): self.assertEqual(page.status_code, 200) page = self.c.get("{}/changes/types/".format(base_url)) self.assertEqual(page.status_code, 200) + page = self.c.get("{}/changes/intervals/".format(base_url)) + self.assertEqual(page.status_code, 200) page = self.c.get("{}/feeding/amounts/".format(base_url)) self.assertEqual(page.status_code, 200) page = self.c.get("{}/feeding/duration/".format(base_url)) self.assertEqual(page.status_code, 200) + page = self.c.get("{}/feeding/intervals/".format(base_url)) + self.assertEqual(page.status_code, 200) page = self.c.get("{}/head-circumference/head-circumference/".format(base_url)) self.assertEqual(page.status_code, 200) diff --git a/reports/urls.py b/reports/urls.py index 17dae6c5..a7f8578d 100644 --- a/reports/urls.py +++ b/reports/urls.py @@ -31,6 +31,11 @@ urlpatterns = [ views.DiaperChangeTypesChildReport.as_view(), name="report-diaperchange-types-child", ), + path( + "children//reports/changes/intervals/", + views.DiaperChangeIntervalsChildReport.as_view(), + name="report-diaperchange-intervals-child", + ), path( "children//reports/feeding/amounts/", views.FeedingAmountsChildReport.as_view(), @@ -56,6 +61,11 @@ urlpatterns = [ views.PumpingAmounts.as_view(), name="report-pumping-amounts-child", ), + path( + "children//reports/feeding/intervals/", + views.FeedingIntervalsChildReport.as_view(), + name="report-feeding-intervals-child", + ), path( "children//reports/sleep/pattern/", views.SleepPatternChildReport.as_view(), diff --git a/reports/views.py b/reports/views.py index 95a6988e..6f340591 100644 --- a/reports/views.py +++ b/reports/views.py @@ -80,7 +80,7 @@ class DiaperChangeTypesChildReport(PermissionRequiredMixin, DetailView): model = models.Child permission_required = ("core.view_child",) - template_name = "reports/diaperchange_types.html" + template_name = "reports/diaperchange_intervals.html" def get_context_data(self, **kwargs): context = super(DiaperChangeTypesChildReport, self).get_context_data(**kwargs) @@ -91,6 +91,26 @@ class DiaperChangeTypesChildReport(PermissionRequiredMixin, DetailView): return context +class DiaperChangeIntervalsChildReport(PermissionRequiredMixin, DetailView): + """ + Graph of diaper change intervals. + """ + + model = models.Child + permission_required = ("core.view_child",) + template_name = "reports/diaperchange_intervals.html" + + def get_context_data(self, **kwargs): + context = super(DiaperChangeIntervalsChildReport, self).get_context_data( + **kwargs + ) + child = context["object"] + changes = models.DiaperChange.objects.filter(child=child) + if changes: + context["html"], context["js"] = graphs.diaperchange_intervals(changes) + return context + + class FeedingAmountsChildReport(PermissionRequiredMixin, DetailView): """ Graph of daily feeding amounts over time. @@ -137,6 +157,23 @@ class FeedingDurationChildReport(PermissionRequiredMixin, DetailView): return context +class FeedingIntervalsChildReport(PermissionRequiredMixin, DetailView): + """ + Graph of diaper change intervals. + """ + + model = models.Child + permission_required = ("core.view_child",) + template_name = "reports/feeding_intervals.html" + + def get_context_data(self, **kwargs): + context = super(FeedingIntervalsChildReport, self).get_context_data(**kwargs) + child = context["object"] + instances = models.Feeding.objects.filter(child=child) + if instances: + context["html"], context["js"] = graphs.feeding_intervals(instances) + return context + class HeadCircumferenceChangeChildReport(PermissionRequiredMixin, DetailView): """ Graph of head circumference change over time.