Skip to content

Commit

Permalink
Add Feeding Patterns report
Browse files Browse the repository at this point in the history
  • Loading branch information
seanwatson authored and cdubz committed Jun 15, 2024
1 parent 22e1dc6 commit fca2e25
Show file tree
Hide file tree
Showing 8 changed files with 298 additions and 0 deletions.
1 change: 1 addition & 0 deletions reports/graphs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .feeding_amounts import feeding_amounts # NOQA
from .feeding_duration import feeding_duration # NOQA
from .feeding_intervals import feeding_intervals # NOQA
from .feeding_pattern import feeding_pattern # NOQA
from .head_circumference_change import head_circumference_change # NOQA
from .height_change import height_change # NOQA
from .pumping_amounts import pumping_amounts # NOQA
Expand Down
225 changes: 225 additions & 0 deletions reports/graphs/feeding_pattern.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
# -*- coding: utf-8 -*-
from collections import OrderedDict

from django.utils import timezone, formats
from django.utils.translation import gettext as _

import plotly.offline as plotly
import plotly.graph_objs as go

from core.utils import duration_string

from reports import utils

from datetime import timedelta

FEEDING_COLOR = "rgb(35, 110, 150)"
NOT_FEEDING_COLOR = "rgba(255, 255, 255, 0)"


def feeding_pattern(feedings):
"""
Create a graph showing blocked out periods of feeding during each day.
:param feedings: a QuerySet of Feeding instances.
:returns: a tuple of the graph's html and javascript.
"""
last_end_time = None
adjustment = None

first_day = timezone.localtime(feedings.first().start)
last_day = timezone.localtime(feedings.last().end)
days = _init_days(first_day, last_day)

for feeding in feedings:
start_time = timezone.localtime(feeding.start)
end_time = timezone.localtime(feeding.end)
start_date = start_time.date().isoformat()
end_date = end_time.date().isoformat()
duration = feeding.duration

# Check if the previous entry crossed midnight (see below).
if adjustment:
_add_adjustment(adjustment, days)
last_end_time = timezone.localtime(adjustment["end_time"])
adjustment = None

# If the dates do not match, set up an adjustment for the next day.
if end_time.date() != start_time.date():
adj_start_time = end_time.replace(hour=0, minute=0, second=0)
adjustment = {
"column": end_date,
"start_time": adj_start_time,
"end_time": end_time,
"duration": end_time - adj_start_time,
}

# Adjust end_time for the current entry.
end_time = end_time.replace(
year=start_time.year,
month=start_time.month,
day=start_time.day,
hour=23,
minute=59,
second=0,
)
duration = end_time - start_time

if last_end_time:
if last_end_time.date() < start_time.date():
last_end_time = start_time.replace(hour=0, minute=0, second=0)

if not last_end_time:
last_end_time = start_time.replace(hour=0, minute=0, second=0)

# Not feeding time.
days[start_date].append(
{"time": (start_time - last_end_time).seconds / 60, "label": None}
)

# Feeding time.
days[start_date].append(
{
"time": duration.seconds / 60,
"label": _format_label(duration, start_time, end_time),
}
)

# Update the previous entry duration if an offset change occurred.
# This can happen when an entry crosses a daylight savings time change.
if start_time.utcoffset() != end_time.utcoffset():
diff = start_time.utcoffset() - end_time.utcoffset()
duration -= timezone.timedelta(seconds=diff.seconds)
yesterday = end_time - timezone.timedelta(days=1)
yesterday = yesterday.date().isoformat()
days[yesterday][len(days[yesterday]) - 1] = {
"time": duration.seconds / 60,
"label": _format_label(duration, start_time, end_time),
}

last_end_time = end_time

# Handle any left over adjustment (if the last entry crossed midnight).
if adjustment:
_add_adjustment(adjustment, days)

# Create dates for x-axis using a 12:00:00 time to ensure correct
# positioning of bars (covering entire day).
dates = []
for time in list(days.keys()):
dates.append("{} 12:00:00".format(time))

traces = []
color = NOT_FEEDING_COLOR

# Set iterator and determine maximum iteration for dates.
i = 0
max_i = 0
for date_times in days.values():
max_i = max(len(date_times), max_i)
while i < max_i:
y = {}
text = {}
for date in days.keys():
try:
y[date] = days[date][i]["time"]
text[date] = days[date][i]["label"]
except IndexError:
y[date] = None
text[date] = None
i += 1
traces.append(
go.Bar(
x=dates,
y=list(y.values()),
hovertext=list(text.values()),
# `hoverinfo` is deprecated but if we use the new `hovertemplate`
# the "filler" areas for awake time get a hover that says "null"
# and there is no way to prevent this currently with Plotly.
hoverinfo="text",
marker={"color": color},
showlegend=False,
)
)
if color == NOT_FEEDING_COLOR:
color = FEEDING_COLOR
else:
color = NOT_FEEDING_COLOR

layout_args = utils.default_graph_layout_options()
layout_args["margin"]["b"] = 100

layout_args["barmode"] = "stack"
layout_args["bargap"] = 0
layout_args["hovermode"] = "closest"
layout_args["title"] = _("<b>Feeding Pattern</b>")
layout_args["height"] = 800

layout_args["xaxis"]["title"] = _("Date")
layout_args["xaxis"]["tickangle"] = -65
layout_args["xaxis"]["tickformat"] = "%b %e\n%Y"
layout_args["xaxis"]["ticklabelmode"] = "period"
layout_args["xaxis"]["rangeselector"] = utils.rangeselector_date()

start = timezone.localtime().strptime("12:00 AM", "%I:%M %p")
ticks = OrderedDict()
ticks[0] = start.strftime("%I:%M %p")
for i in range(0, 60 * 24, 30):
ticks[i] = formats.time_format(
start + timezone.timedelta(minutes=i), "TIME_FORMAT"
)

layout_args["yaxis"]["title"] = _("Time of day")
layout_args["yaxis"]["range"] = [24 * 60, 0]
layout_args["yaxis"]["tickmode"] = "array"
layout_args["yaxis"]["tickvals"] = list(ticks.keys())
layout_args["yaxis"]["ticktext"] = list(ticks.values())
layout_args["yaxis"]["tickfont"] = {"size": 10}

fig = go.Figure({"data": traces, "layout": go.Layout(**layout_args)})
output = plotly.plot(fig, output_type="div", include_plotlyjs=False)
return utils.split_graph_output(output)


def _init_days(first_day, last_day):
period = (last_day.date() - first_day.date()).days + 1

def new_day(d):
return (first_day + timedelta(days=d)).date().isoformat()

return {new_day(day): [] for day in range(period)}


def _add_adjustment(adjustment, days):
"""
Adds "adjustment" data for entries that cross midnight.
:param adjustment: Column, start time, end time, and duration of entry.
:param blocks: List of days
"""
column = adjustment.pop("column")
if not column in days:
days[column] = []
# Fake (0) entry to keep the color switching logic working.
days[column].append({"time": 0, "label": 0})

# Real adjustment entry.
days[column].append(
{
"time": adjustment["duration"].seconds / 60,
"label": _format_label(**adjustment),
}
)


def _format_label(duration, start_time, end_time):
"""
Formats a time block label.
:param duration: Duration.
:param start_time: Start time.
:param end_time: End time.
:return: Formatted string with duration, start, and end time.
"""
return "Feeding {} ({} to {})".format(
duration_string(duration),
formats.time_format(start_time, "TIME_FORMAT"),
formats.time_format(end_time, "TIME_FORMAT"),
)
10 changes: 10 additions & 0 deletions reports/templates/reports/feeding_pattern.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% extends 'reports/report_base.html' %}
{% load i18n %}
{% block title %}
{% trans "Feeding Pattern" %} - {{ object }}
{% endblock %}
{% block breadcrumbs %}
{{ block.super }}
{% include 'reports/breadcrumb_common_chunk.html' with target_url='reports:report-feeding-pattern-child' %}
<li class="breadcrumb-item active" aria-current="page">{% trans "Feeding Pattern" %}</li>
{% endblock %}
2 changes: 2 additions & 0 deletions reports/templates/reports/report_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ <h1>Reports</h1>
class="list-group-item list-group-item-action">{% trans "Feeding Durations (Average)" %}</a>
<a href="{% url 'reports:report-feeding-intervals-child' object.slug %}"
class="list-group-item list-group-item-action">{% trans "Feeding Intervals" %}</a>
<a href="{% url 'reports:report-feeding-pattern-child' object.slug %}"
class="list-group-item list-group-item-action">{% trans "Feeding Pattern" %}</a>
<a href="{% url 'reports:report-head-circumference-change-child' object.slug %}"
class="list-group-item list-group-item-action">{% trans "Head Circumference" %}</a>
<a href="{% url 'reports:report-height-change-child' object.slug %}"
Expand Down
30 changes: 30 additions & 0 deletions reports/tests/graphs/tests_feeding_pattern.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
import datetime as dt

from django.test import TestCase
from django.utils import timezone

from core import models
from reports.graphs import feeding_pattern


class FeedingPatternTestCase(TestCase):
def setUp(self):
self.original_tz = timezone.get_current_timezone()
self.tz = dt.timezone(dt.timedelta(days=-1, hours=1))
timezone.activate(self.tz)

def tearDown(self):
timezone.activate(self.original_tz)

def test_feeding_pattern(self):
c = models.Child(birth_date=dt.datetime.now())
c.save()

models.Feeding.objects.create(
child=c,
start=dt.datetime(2000, 1, 1, 0, 0, tzinfo=dt.timezone.utc),
end=dt.datetime(2000, 1, 1, 0, 1, tzinfo=dt.timezone.utc),
)

feeding_pattern(models.Feeding.objects.order_by("start"))
2 changes: 2 additions & 0 deletions reports/tests/tests_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ def test_graph_child_views(self):
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("{}/feeding/pattern/".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)
Expand Down
5 changes: 5 additions & 0 deletions reports/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
views.FeedingIntervalsChildReport.as_view(),
name="report-feeding-intervals-child",
),
path(
"children/<str:slug>/reports/feeding/pattern/",
views.FeedingPatternChildReport.as_view(),
name="report-feeding-pattern-child",
),
path(
"children/<str:slug>/reports/sleep/pattern/",
views.SleepPatternChildReport.as_view(),
Expand Down
23 changes: 23 additions & 0 deletions reports/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,29 @@ def get_context_data(self, **kwargs):
return context


class FeedingPatternChildReport(PermissionRequiredMixin, DetailView):
"""
Graph of feeding pattern.
"""

model = models.Child
permission_required = ("core.view_child",)
template_name = "reports/feeding_pattern.html"

def __init__(self):
super(FeedingPatternChildReport, self).__init__()
self.html = ""
self.js = ""

def get_context_data(self, **kwargs):
context = super(FeedingPatternChildReport, self).get_context_data(**kwargs)
child = context["object"]
instances = models.Feeding.objects.filter(child=child).order_by("start")
if instances:
context["html"], context["js"] = graphs.feeding_pattern(instances)
return context


class HeadCircumferenceChangeChildReport(PermissionRequiredMixin, DetailView):
"""
Graph of head circumference change over time.
Expand Down

0 comments on commit fca2e25

Please sign in to comment.