feat: Weight percentiles report #512 (#708)

* feat: weight percentiles report
https://github.com/babybuddy/babybuddy/issues/512

* fix: formatting on weight percentile graph to extend out only 1 day more than the last weigh in date
https://github.com/babybuddy/babybuddy/pull/708#discussion_r1332335789

* feat: pre-commit lint with black hook
tested and stops the python commit if formatting is bad. then it runs black and fixes the formatting, resulting in a new git change to add/commit again

---------

Co-authored-by: Christopher Charbonneau Wells <10456740+cdubz@users.noreply.github.com>
This commit is contained in:
Michael Salaverry 2023-09-27 16:24:53 +03:00 committed by GitHub
parent cb2197e3bb
commit 8d8006ab71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 3950 additions and 12 deletions

View File

@ -4,8 +4,16 @@
"extensions": [ "extensions": [
"bbenoist.Nix", "bbenoist.Nix",
"ms-python.python", "ms-python.python",
"batisteo.vscode-django" "batisteo.vscode-django",
] "ms-python.black-formatter"
],
"settings": {
"[python]": {
"editor": {
"defaultFormatter": "ms-python.black-formatter"
}
}
}
} }
}, },
"image": "ghcr.io/cachix/devenv:latest", "image": "ghcr.io/cachix/devenv:latest",

View File

@ -0,0 +1,92 @@
# Generated by Django 4.2.5 on 2023-09-20 10:56
from django.db import migrations, models, transaction
from django.utils import dateparse
import csv
def add_to_ORM(WeightPercentile: models.Model, alias, filepath: str, sex: str):
with open(filepath) as csvfile:
weight_reader = csv.DictReader(csvfile)
data_list = []
for row in weight_reader:
data_list.append(
WeightPercentile(
age_in_days=dateparse.parse_duration(f'P{row["Age"]}D'),
sex=sex,
p3_weight=row["P3"],
p15_weight=row["P15"],
p50_weight=row["P50"],
p85_weight=row["P85"],
p97_weight=row["P97"],
)
)
WeightPercentile.objects.using(alias).bulk_create(data_list, batch_size=50)
def insert_weight_percentiles(apps, schema_editor):
WeightPercentile: models.Model = apps.get_model("core", "WeightPercentile")
db_alias = schema_editor.connection.alias
with transaction.atomic():
add_to_ORM(
WeightPercentile,
db_alias,
"core/migrations/weight_percentile_boys.csv",
"boy",
)
with transaction.atomic():
add_to_ORM(
WeightPercentile,
db_alias,
"core/migrations/weight_percentile_girls.csv",
"girl",
)
def remove_weight_percentiles(apps, schema_editor):
WeightPercentile: models.Model = apps.get_model("core", "WeightPercentile")
db_alias = schema_editor.connection.alias
with transaction.atomic():
WeightPercentile.objects.using(db_alias).all().delete()
class Migration(migrations.Migration):
dependencies = [
("core", "0029_alter_pumping_options_remove_pumping_time_and_more"),
]
operations = [
migrations.CreateModel(
name="WeightPercentile",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("age_in_days", models.DurationField()),
("p3_weight", models.FloatField()),
("p15_weight", models.FloatField()),
("p50_weight", models.FloatField()),
("p85_weight", models.FloatField()),
("p97_weight", models.FloatField()),
(
"sex",
models.CharField(
choices=[("girl", "Girl"), ("boy", "Boy")], max_length=255
),
),
],
),
migrations.AddConstraint(
model_name="weightpercentile",
constraint=models.UniqueConstraint(
fields=("age_in_days", "sex"), name="unique_age_sex"
),
),
migrations.RunPython(insert_weight_percentiles, remove_weight_percentiles),
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -699,3 +699,31 @@ class Weight(models.Model):
def clean(self): def clean(self):
validate_date(self.date, "date") validate_date(self.date, "date")
class WeightPercentile(models.Model):
model_name = "weight percentile"
age_in_days = models.DurationField(null=False)
p3_weight = models.FloatField(null=False)
p15_weight = models.FloatField(null=False)
p50_weight = models.FloatField(null=False)
p85_weight = models.FloatField(null=False)
p97_weight = models.FloatField(null=False)
sex = models.CharField(
null=False,
max_length=255,
choices=[
("girl", _("Girl")),
("boy", _("Boy")),
],
)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["age_in_days", "sex"], name="unique_age_sex"
)
]
def __str__(self):
return f"Sex: {self.sex}, Age: {self.age_in_days} days, p3: {self.p3_weight} kg, p15: {self.p15_weight} kg, p50: {self.p50_weight} kg, p85: {self.p85_weight} kg, p97: {self.p97_weight} kg"

View File

@ -9,6 +9,7 @@
pkgs.pipenv pkgs.pipenv
pkgs.nodejs_18 pkgs.nodejs_18
pkgs.nodePackages.gulp pkgs.nodePackages.gulp
pkgs.sqlite
]; ];
# https://devenv.sh/scripts/ # https://devenv.sh/scripts/
@ -29,14 +30,21 @@
devcontainer = { devcontainer = {
enable = true; enable = true;
settings.customizations.vscode.settings."[python]".editor.defaultFormatter = "ms-python.black-formatter";
settings.customizations.vscode.extensions = [ settings.customizations.vscode.extensions = [
"bbenoist.Nix" "bbenoist.Nix"
"ms-python.python" "ms-python.python"
"batisteo.vscode-django" "batisteo.vscode-django"
"ms-python.black-formatter"
]; ];
}; };
# services.nginx.enable = true; # services.nginx.enable = true;
pre-commit.hooks = {
# format Python code
black.enable = true;
};
# See full reference at https://devenv.sh/reference/options/ # See full reference at https://devenv.sh/reference/options/
} }

View File

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import datetime, timedelta
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.db.models.manager import BaseManager
import plotly.offline as plotly import plotly.offline as plotly
import plotly.graph_objs as go import plotly.graph_objs as go
@ -7,28 +9,99 @@ import plotly.graph_objs as go
from reports import utils from reports import utils
def weight_change(objects): def weight_change(
actual_weights: BaseManager, percentile_weights: BaseManager, birthday: datetime
):
""" """
Create a graph showing weight over time. Create a graph showing weight over time.
:param objects: a QuerySet of Weight instances. :param actual_weights: a QuerySet of Weight instances.
:param percentile_weights: a QuerySet of Weight Percentile instances.
:param birthday: a datetime of the child's birthday
:returns: a tuple of the the graph's html and javascript. :returns: a tuple of the the graph's html and javascript.
""" """
objects = objects.order_by("-date") actual_weights = actual_weights.order_by("-date")
trace = go.Scatter( weighing_dates: list[datetime] = list(actual_weights.values_list("date", flat=True))
measured_weights = list(actual_weights.values_list("weight", flat=True))
actual_weights_trace = go.Scatter(
name=_("Weight"), name=_("Weight"),
x=list(objects.values_list("date", flat=True)), x=weighing_dates,
y=list(objects.values_list("weight", flat=True)), y=measured_weights,
fill="tozeroy", fill="tozeroy",
mode="lines+markers",
) )
if percentile_weights:
dates = list(
map(
lambda timedelta: birthday + timedelta,
percentile_weights.values_list("age_in_days", flat=True),
)
)
# reduce percentile data xrange to end 1 day after last weigh in for formatting purposes
# https://github.com/babybuddy/babybuddy/pull/708#discussion_r1332335789
last_date_for_percentiles = max(weighing_dates) + timedelta(days=2)
dates = dates[: dates.index(last_date_for_percentiles)]
percentile_weight_3_trace = go.Scatter(
name=_("P3"),
x=dates,
y=list(percentile_weights.values_list("p3_weight", flat=True)),
line={"color": "red"},
)
percentile_weight_15_trace = go.Scatter(
name=_("P15"),
x=dates,
y=list(percentile_weights.values_list("p15_weight", flat=True)),
line={"color": "orange"},
)
percentile_weight_50_trace = go.Scatter(
name=_("P50"),
x=dates,
y=list(percentile_weights.values_list("p50_weight", flat=True)),
line={"color": "green"},
)
percentile_weight_85_trace = go.Scatter(
name=_("P85"),
x=dates,
y=list(percentile_weights.values_list("p85_weight", flat=True)),
line={"color": "orange"},
)
percentile_weight_97_trace = go.Scatter(
name=_("P97"),
x=dates,
y=list(percentile_weights.values_list("p97_weight", flat=True)),
line={"color": "red"},
)
data = [
actual_weights_trace,
]
layout_args = utils.default_graph_layout_options() layout_args = utils.default_graph_layout_options()
layout_args["barmode"] = "stack" layout_args["barmode"] = "stack"
layout_args["title"] = _("<b>Weight</b>") layout_args["title"] = _("<b>Weight</b>")
layout_args["xaxis"]["title"] = _("Date") layout_args["xaxis"]["title"] = _("Date")
layout_args["xaxis"]["rangeselector"] = utils.rangeselector_date() layout_args["xaxis"]["rangeselector"] = utils.rangeselector_date()
layout_args["yaxis"]["title"] = _("Weight") layout_args["yaxis"]["title"] = _("Weight")
if percentile_weights:
# zoom in on the relevant dates
layout_args["xaxis"]["range"] = [
birthday,
max(weighing_dates) + timedelta(days=1),
]
layout_args["yaxis"]["range"] = [0, max(measured_weights) * 1.5]
data.extend(
[
percentile_weight_97_trace,
percentile_weight_85_trace,
percentile_weight_50_trace,
percentile_weight_15_trace,
percentile_weight_3_trace,
]
)
fig = go.Figure({"data": [trace], "layout": go.Layout(**layout_args)}) fig = go.Figure({"data": data, "layout": go.Layout(**layout_args)})
output = plotly.plot(fig, output_type="div", include_plotlyjs=False) output = plotly.plot(fig, output_type="div", include_plotlyjs=False)
return utils.split_graph_output(output) return utils.split_graph_output(output)

View File

@ -30,6 +30,8 @@
<a href="{% url 'reports:report-temperature-change-child' object.slug %}" class="list-group-item list-group-item-action">{% trans "Temperature" %}</a> <a href="{% url 'reports:report-temperature-change-child' object.slug %}" class="list-group-item list-group-item-action">{% trans "Temperature" %}</a>
<a href="{% url 'reports:report-tummy-time-duration-child' object.slug %}" class="list-group-item list-group-item-action">{% trans "Tummy Time Durations (Sum)" %}</a> <a href="{% url 'reports:report-tummy-time-duration-child' object.slug %}" class="list-group-item list-group-item-action">{% trans "Tummy Time Durations (Sum)" %}</a>
<a href="{% url 'reports:report-weight-change-child' object.slug %}" class="list-group-item list-group-item-action">{% trans "Weight" %}</a> <a href="{% url 'reports:report-weight-change-child' object.slug %}" class="list-group-item list-group-item-action">{% trans "Weight" %}</a>
<a href="{% url 'reports:report-weight-change-child-sex' object.slug 'boy' %}" class="list-group-item list-group-item-action">{% trans "WHO Weight Percentiles for Boys in kg" %}</a>
<a href="{% url 'reports:report-weight-change-child-sex' object.slug 'girl' %}" class="list-group-item list-group-item-action">{% trans "WHO Weight Percentiles for Girls in kg" %}</a>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -81,4 +81,9 @@ urlpatterns = [
views.WeightChangeChildReport.as_view(), views.WeightChangeChildReport.as_view(),
name="report-weight-change-child", name="report-weight-change-child",
), ),
path(
"children/<str:slug>/reports/weight/<sex>/",
views.WeightChangeChildReport.as_view(),
name="report-weight-change-child-sex",
),
] ]

View File

@ -295,7 +295,13 @@ class WeightChangeChildReport(PermissionRequiredMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(WeightChangeChildReport, self).get_context_data(**kwargs) context = super(WeightChangeChildReport, self).get_context_data(**kwargs)
child = context["object"] child = context["object"]
objects = models.Weight.objects.filter(child=child) birthday = child.birth_date
if objects: actual_weights = models.Weight.objects.filter(child=child)
context["html"], context["js"] = graphs.weight_change(objects) percentile_weights = models.WeightPercentile.objects.filter(
sex=self.kwargs.get("sex")
)
if actual_weights:
context["html"], context["js"] = graphs.weight_change(
actual_weights, percentile_weights, birthday
)
return context return context