Skip to content

Commit a529fc0

Browse files
committed
Alternate alternate alternate approach (last one I swear)
1 parent ce82b7a commit a529fc0

File tree

4 files changed

+114
-38
lines changed

4 files changed

+114
-38
lines changed

dashboard/models.py

+36-4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
77
from django.contrib.contenttypes.models import ContentType
88
from django.db import connections, models
9+
from django.db.models.functions import JSONObject
910
from django.utils.translation import gettext_lazy as _
1011
from django_hosts.resolvers import reverse
1112

@@ -33,6 +34,35 @@ def __str__(self):
3334
return self.name
3435

3536

37+
class MetricQuerySet(models.QuerySet):
38+
def with_latest(self):
39+
"""
40+
Annotate the queryset with a `latest` JSON object containing two keys:
41+
* `measurement` (int): the value of the most recent datum for that metric
42+
* `timestamp` (str): the timestamp of the most recent datum
43+
"""
44+
data = Datum.objects.filter(
45+
content_type=self.model.content_type(),
46+
object_id=models.OuterRef("pk"),
47+
)
48+
jsonobj = JSONObject(
49+
measurement=models.F("measurement"),
50+
timestamp=models.F("timestamp"),
51+
)
52+
latest = models.Subquery(data.values_list(jsonobj).order_by("-timestamp")[:1])
53+
54+
return self.annotate(latest=latest)
55+
56+
def for_dashboard(self):
57+
"""
58+
Return a queryset optimized for being displayed on the dashboard index
59+
page.
60+
"""
61+
return (
62+
self.filter(show_on_dashboard=True).select_related("category").with_latest()
63+
)
64+
65+
3666
class Metric(models.Model):
3767
name = models.CharField(max_length=300)
3868
slug = models.SlugField()
@@ -49,6 +79,8 @@ class Metric(models.Model):
4979
unit = models.CharField(max_length=100)
5080
unit_plural = models.CharField(max_length=100)
5181

82+
objects = MetricQuerySet.as_manager()
83+
5284
class Meta:
5385
abstract = True
5486

@@ -116,13 +148,13 @@ def _gather_data_periodic(self, since, period):
116148
AND object_id = %s
117149
AND timestamp >= %s
118150
GROUP BY 1;""",
119-
[period, OFFSET, self.content_type.id, self.id, since],
151+
[period, OFFSET, self.content_type().id, self.id, since],
120152
)
121153
return [(calendar.timegm(t.timetuple()), float(m)) for (t, m) in c.fetchall()]
122154

123-
@property
124-
def content_type(self):
125-
return ContentType.objects.get_for_model(self)
155+
@classmethod
156+
def content_type(cls):
157+
return ContentType.objects.get_for_model(cls)
126158

127159

128160
class TracTicketMetric(Metric):

dashboard/templates/dashboard/index.html

+13-11
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,25 @@
33

44
{% block content %}
55
<div class="dashboard-index">
6-
{% for report in data %}
7-
{% ifchanged report.metric.category %}
8-
{% if report.metric.category %}<h2>{{ report.metric.category }}</h2>{% endif %}
6+
{% for metric in data %}
7+
{% ifchanged metric.category %}
8+
{% if metric.category %}<h2>{{ metric.category }}</h2>{% endif %}
99
{% endifchanged %}
10-
<div class="metric{% if report.metric.show_sparkline %} has-sparkline{% endif %}">
11-
<h3><a href="{{ report.metric.link }}">{{ report.metric.name }}</a></h3>
10+
<div class="metric{% if metric.show_sparkline %} has-sparkline{% endif %}">
11+
<h3><a href="{{ metric.link }}">{{ metric.name }}</a></h3>
1212
<div class="value" >
13-
<a href="{{ report.metric.get_absolute_url }}">{{ report.latest.measurement }}{% if report.metric.unit == "%" %}%{% endif %}</a>
13+
<a href="{{ metric.get_absolute_url }}">{{ metric.latest.measurement }}{% if metric.unit == "%" %}%{% endif %}</a>
1414
<div class="timestamp">&nbsp;</div>
1515
</div>
16-
{% if report.metric.show_sparkline %}
17-
<div class="sparkline" id="spark{{ forloop.counter0 }}" data-path="{% url "metric-list" host "dashboard" %}" data-metric="{{ report.metric.slug }}"></div>
16+
{% if metric.show_sparkline %}
17+
<div class="sparkline" id="spark{{ forloop.counter0 }}" data-path="{% url "metric-list" host "dashboard" %}" data-metric="{{ metric.slug }}"></div>
1818
{% endif %}
1919
</div>
2020
{% endfor %}
21-
<p class="updated">
22-
{% blocktranslate with timestamp=data.0.latest.timestamp|timesince %}Updated {{ timestamp }} ago.{% endblocktranslate %}
23-
</p>
21+
{% if last_updated %}
22+
<p class="updated">
23+
{% blocktranslate with timestamp=last_updated|timesince %}Updated {{ timestamp }} ago.{% endblocktranslate %}
24+
</p>
25+
{% endif %}
2426
</div>
2527
{% endblock %}

dashboard/tests.py

+49-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .models import (
1717
METRIC_PERIOD_DAILY,
1818
METRIC_PERIOD_WEEKLY,
19+
Category,
1920
GithubItemCountMetric,
2021
GitHubSearchCountMetric,
2122
Metric,
@@ -37,8 +38,7 @@ def test_index(self):
3738
metric.data.create(measurement=42)
3839

3940
request = self.factory.get(reverse("dashboard-index", host="dashboard"))
40-
with self.assertNumQueries(7):
41-
response = index(request)
41+
response = index(request)
4242
self.assertContains(response, "Development dashboard")
4343
self.assertEqual(response.content.count(b'<div class="metric'), 13)
4444
self.assertEqual(response.content.count(b"42"), 13)
@@ -72,6 +72,53 @@ def test_metric_json(self):
7272
self.assertEqual(response.status_code, 200)
7373

7474

75+
class AbstractMetricTestCase(TestCase):
76+
@classmethod
77+
def setUpTestData(cls):
78+
category = Category.objects.create(name="test category")
79+
cls.metrics = [
80+
TracTicketMetric.objects.create(
81+
slug=f"test{i}", name=f"Test metric {i}", category=category
82+
)
83+
for i in range(3)
84+
]
85+
for metric, measurement, year in [
86+
(0, 1, 2020),
87+
(0, 2, 2021),
88+
(0, 3, 2022),
89+
(1, 4, 2023),
90+
]:
91+
cls.metrics[metric].data.create(
92+
measurement=measurement,
93+
timestamp=datetime.datetime(year, 1, 1),
94+
)
95+
96+
def test_with_latest(self):
97+
self.assertQuerySetEqual(
98+
TracTicketMetric.objects.with_latest().order_by("name"),
99+
[
100+
(
101+
"Test metric 0",
102+
{"measurement": 3, "timestamp": "2022-01-01T00:00:00-06:00"},
103+
),
104+
(
105+
"Test metric 1",
106+
{"measurement": 4, "timestamp": "2023-01-01T00:00:00-06:00"},
107+
),
108+
("Test metric 2", None),
109+
],
110+
transform=attrgetter("name", "latest"),
111+
)
112+
113+
def test_for_dashboard(self):
114+
with self.assertNumQueries(1):
115+
for row in TracTicketMetric.objects.for_dashboard():
116+
# weird asserts to make sure the related objects are evaluated
117+
self.assertTrue(row.category.name)
118+
self.assertTrue(row.latest is None or row.latest["timestamp"])
119+
self.assertTrue(row.latest is None or row.latest["measurement"])
120+
121+
75122
class MetricMixin:
76123
def test_str(self):
77124
self.assertEqual(str(self.instance), self.instance.name)

dashboard/views.py

+16-21
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from django.shortcuts import render
88
from django.utils.translation import gettext as _
99

10-
from .models import Datum, Metric
10+
from .models import Metric
1111
from .utils import generation_key
1212

1313

@@ -17,28 +17,23 @@ def index(request):
1717

1818
data = cache.get(key, version=generation)
1919
if data is None:
20-
metrics = []
21-
for MC in Metric.__subclasses__():
22-
metrics.extend(
23-
MC.objects.filter(show_on_dashboard=True).select_related("category")
24-
)
25-
metrics = sorted(metrics, key=operator.attrgetter("display_position"))
26-
27-
metric_latest_querysets = [
28-
metric.data.order_by("-timestamp")[0:1] for metric in metrics
29-
]
30-
data_latest = Datum.objects.none().union(*metric_latest_querysets)
31-
latest_by_metric = {
32-
(datum.content_type_id, datum.object_id): datum for datum in data_latest
33-
}
34-
35-
data = []
36-
for metric in metrics:
37-
latest = latest_by_metric.get((metric.content_type.pk, metric.pk))
38-
data.append({"metric": metric, "latest": latest})
20+
data = [m for MC in Metric.__subclasses__() for m in MC.objects.for_dashboard()]
21+
data.sort(key=operator.attrgetter("display_position"))
3922
cache.set(key, data, 60 * 60, version=generation)
4023

41-
return render(request, "dashboard/index.html", {"data": data})
24+
# Due to the way `with_latest()` is implemented, the timestamps we get back
25+
# are actually strings (because JSON) so they need converting to proper
26+
# datetime objects first.
27+
timestamps = [
28+
datetime.datetime.fromisoformat(m.latest["timestamp"])
29+
for m in data
30+
if m.latest is not None
31+
]
32+
last_updated = max(timestamps, default=None)
33+
34+
return render(
35+
request, "dashboard/index.html", {"data": data, "last_updated": last_updated}
36+
)
4237

4338

4439
def metric_detail(request, metric_slug):

0 commit comments

Comments
 (0)