Skip to content

Commit 95f2767

Browse files
committed
Fixed #1690 -- fixed trac metrics involving time
This also introduces the fix_trac_metrics management command to help fix collected data.
1 parent ba9446c commit 95f2767

File tree

7 files changed

+394
-35
lines changed

7 files changed

+394
-35
lines changed
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from datetime import date, timedelta
2+
3+
import time_machine
4+
from django.core.management.base import CommandError, LabelCommand
5+
from django.db.models import Case, Max, Min, When
6+
7+
from ...models import TracTicketMetric
8+
9+
10+
def _get_data(metric, options):
11+
"""
12+
Return a queryset of Datum instances for the given metric, taking into
13+
account the from_date/to_date keys of the given options dict.
14+
"""
15+
queryset = metric.data.all()
16+
if options["from_date"]:
17+
queryset = queryset.filter(timestamp__date__gte=options["from_date"])
18+
if options["to_date"]:
19+
queryset = queryset.filter(timestamp__date__lte=options["to_date"])
20+
return queryset
21+
22+
23+
def _daterange(queryset):
24+
"""
25+
Given a queryset of Datum objects, generate all dates (as date objects)
26+
between the earliest and latest data points in the queryset.
27+
"""
28+
aggregated = queryset.aggregate(
29+
start=Min("timestamp__date"), end=Max("timestamp__date")
30+
)
31+
if aggregated["start"] is None or aggregated["end"] is None:
32+
raise ValueError("queryset cannot be empty")
33+
34+
d = aggregated["start"]
35+
while d <= aggregated["end"]:
36+
yield d
37+
d += timedelta(days=1)
38+
39+
40+
def _refetched_case_when(dates, metric):
41+
"""
42+
Refetch the given metric for all the given dates and build a CASE database
43+
expression with one WHEN per date.
44+
"""
45+
whens = []
46+
for d in dates:
47+
with time_machine.travel(d):
48+
whens.append(When(timestamp__date=d, then=metric.fetch()))
49+
return Case(*whens)
50+
51+
52+
class Command(LabelCommand):
53+
help = "Retroactively refetch measurements for Trac metrics."
54+
label = "slug"
55+
56+
def add_arguments(self, parser):
57+
super().add_arguments(parser)
58+
parser.add_argument(
59+
"--yes", action="store_true", help="Commit the changes to the database"
60+
)
61+
parser.add_argument(
62+
"--from-date",
63+
type=date.fromisoformat,
64+
help="Restrict the timestamp range (ISO format)",
65+
)
66+
parser.add_argument(
67+
"--to-date",
68+
type=date.fromisoformat,
69+
help="Restrict the timestamp range (ISO format)",
70+
)
71+
72+
def handle_label(self, label, **options):
73+
try:
74+
metric = TracTicketMetric.objects.get(slug=label)
75+
except TracTicketMetric.DoesNotExist as e:
76+
raise CommandError from e
77+
78+
verbose = int(options["verbosity"]) > 0
79+
80+
if verbose:
81+
self.stdout.write(f"Fixing metric {label}...")
82+
dataset = _get_data(metric, options)
83+
84+
if options["yes"]:
85+
dates = _daterange(dataset)
86+
updated_measurement_expression = _refetched_case_when(dates, metric)
87+
updated = dataset.update(measurement=updated_measurement_expression)
88+
if verbose:
89+
self.stdout.write(self.style.SUCCESS(f"{updated} rows updated"))
90+
else:
91+
if verbose:
92+
self.stdout.write(f"{dataset.count()} rows will be updated.")
93+
self.stdout.write("Re-run the command with --yes to apply the change")

dashboard/tests.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import datetime
22
import json
3+
from operator import attrgetter
34
from unittest import mock
45

56
import requests_mock
@@ -10,6 +11,7 @@
1011

1112
from tracdb.models import Ticket
1213
from tracdb.testutils import TracDBCreateDatabaseMixin
14+
from tracdb.tractime import datetime_to_timestamp
1315

1416
from .models import (
1517
METRIC_PERIOD_DAILY,
@@ -178,3 +180,115 @@ def test_update_metric(self, mocker, mock_reset_generation_key):
178180
self.assertTrue(mock_reset_generation_key.called)
179181
data = GithubItemCountMetric.objects.last().data.last()
180182
self.assertEqual(data.measurement, 10)
183+
184+
185+
class FixTracMetricsCommandTestCase(TracDBCreateDatabaseMixin, TestCase):
186+
databases = {"default", "trac"}
187+
188+
@classmethod
189+
def setUpTestData(cls):
190+
super().setUpTestData()
191+
192+
def dt(*args, **kwargs):
193+
kwargs.setdefault("tzinfo", datetime.UTC)
194+
return datetime.datetime(*args, **kwargs)
195+
196+
def ts(*args, **kwargs):
197+
return datetime_to_timestamp(dt(*args, **kwargs))
198+
199+
for day in range(7):
200+
Ticket.objects.create(_time=ts(2024, 1, day + 1))
201+
202+
cls.metric_today = TracTicketMetric.objects.create(
203+
slug="today", query="time=today.."
204+
)
205+
cls.metric_week = TracTicketMetric.objects.create(
206+
slug="week", query="time=thisweek.."
207+
)
208+
209+
def test_command_today(self):
210+
datum = self.metric_today.data.create(
211+
measurement=0, timestamp="2024-01-01T00:00:00"
212+
)
213+
management.call_command("fix_trac_metrics", "today", yes=True, verbosity=0)
214+
datum.refresh_from_db()
215+
self.assertEqual(datum.measurement, 1)
216+
217+
def test_command_week(self):
218+
datum = self.metric_week.data.create(
219+
measurement=0, timestamp="2024-01-07T00:00:00"
220+
)
221+
management.call_command("fix_trac_metrics", "week", yes=True, verbosity=0)
222+
datum.refresh_from_db()
223+
self.assertEqual(datum.measurement, 7)
224+
225+
def test_command_safe_by_default(self):
226+
datum = self.metric_today.data.create(
227+
measurement=0, timestamp="2024-01-01T00:00:00"
228+
)
229+
management.call_command("fix_trac_metrics", "today", verbosity=0)
230+
datum.refresh_from_db()
231+
self.assertEqual(datum.measurement, 0)
232+
233+
def test_multiple_measurements(self):
234+
self.metric_today.data.create(measurement=0, timestamp="2024-01-01T00:00:00")
235+
self.metric_today.data.create(measurement=0, timestamp="2024-01-02T00:00:00")
236+
self.metric_today.data.create(measurement=0, timestamp="2024-01-03T00:00:00")
237+
management.call_command("fix_trac_metrics", "today", yes=True, verbosity=0)
238+
self.assertQuerySetEqual(
239+
self.metric_today.data.order_by("timestamp"),
240+
[1, 1, 1],
241+
transform=attrgetter("measurement"),
242+
)
243+
244+
def test_option_from_date(self):
245+
self.metric_today.data.create(measurement=0, timestamp="2024-01-01T00:00:00")
246+
self.metric_today.data.create(measurement=0, timestamp="2024-01-02T00:00:00")
247+
self.metric_today.data.create(measurement=0, timestamp="2024-01-03T00:00:00")
248+
management.call_command(
249+
"fix_trac_metrics",
250+
"today",
251+
yes=True,
252+
from_date=datetime.date(2024, 1, 2),
253+
verbosity=0,
254+
)
255+
self.assertQuerySetEqual(
256+
self.metric_today.data.order_by("timestamp"),
257+
[0, 1, 1],
258+
transform=attrgetter("measurement"),
259+
)
260+
261+
def test_option_to_date(self):
262+
self.metric_today.data.create(measurement=0, timestamp="2024-01-01T00:00:00")
263+
self.metric_today.data.create(measurement=0, timestamp="2024-01-02T00:00:00")
264+
self.metric_today.data.create(measurement=0, timestamp="2024-01-03T00:00:00")
265+
management.call_command(
266+
"fix_trac_metrics",
267+
"today",
268+
yes=True,
269+
to_date=datetime.date(2024, 1, 2),
270+
verbosity=0,
271+
)
272+
self.assertQuerySetEqual(
273+
self.metric_today.data.order_by("timestamp"),
274+
[1, 1, 0],
275+
transform=attrgetter("measurement"),
276+
)
277+
278+
def test_option_both_to_and_from_date(self):
279+
self.metric_today.data.create(measurement=0, timestamp="2024-01-01T00:00:00")
280+
self.metric_today.data.create(measurement=0, timestamp="2024-01-02T00:00:00")
281+
self.metric_today.data.create(measurement=0, timestamp="2024-01-03T00:00:00")
282+
management.call_command(
283+
"fix_trac_metrics",
284+
"today",
285+
yes=True,
286+
from_date=datetime.date(2024, 1, 2),
287+
to_date=datetime.date(2024, 1, 2),
288+
verbosity=0,
289+
)
290+
self.assertQuerySetEqual(
291+
self.metric_today.data.order_by("timestamp"),
292+
[0, 1, 0],
293+
transform=attrgetter("measurement"),
294+
)

requirements/common.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ requests==2.32.3
2121
sorl-thumbnail==12.11.0
2222
Sphinx==8.1.3
2323
stripe==3.1.0
24+
time-machine==2.15.0

tracdb/models.py

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -43,37 +43,14 @@
4343
4444
"""
4545

46-
import datetime
46+
from datetime import date
4747
from functools import reduce
4848
from operator import and_, or_
4949
from urllib.parse import parse_qs
5050

5151
from django.db import models
5252

53-
_epoc = datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC)
54-
55-
56-
class time_property:
57-
"""
58-
Convert Trac timestamps into UTC datetimes.
59-
60-
See http://trac.edgewall.org/browser//branches/0.12-stable/trac/util/datefmt.py
61-
for Trac's version of all this. Mine's something of a simplification.
62-
63-
Like the rest of this module this is far from perfect -- no setters, for
64-
example! That's good enough for now.
65-
"""
66-
67-
def __init__(self, fieldname):
68-
self.fieldname = fieldname
69-
70-
def __get__(self, instance, owner):
71-
if instance is None:
72-
return self
73-
timestamp = getattr(instance, self.fieldname)
74-
if timestamp is None:
75-
return None
76-
return _epoc + datetime.timedelta(microseconds=timestamp)
53+
from .tractime import dayrange, time_property
7754

7855

7956
class JSONBObjectAgg(models.Aggregate):
@@ -97,7 +74,17 @@ def from_querystring(self, querystring):
9774
filter_kwargs, exclude_kwargs = {}, {}
9875

9976
for field, (value,) in parsed.items():
100-
if field not in model_fields:
77+
if field == "time":
78+
if value == "today..":
79+
timestamp_range = dayrange(date.today(), 1)
80+
elif value == "thisweek..":
81+
timestamp_range = dayrange(date.today(), 7)
82+
else:
83+
raise ValueError(f"Unsupported time value {value}")
84+
85+
filter_kwargs["_time__range"] = timestamp_range
86+
continue
87+
elif field not in model_fields:
10188
custom_lookup_required = True
10289
field = f"custom__{field}"
10390
if value.startswith("!"):

0 commit comments

Comments
 (0)