Skip to content

Commit d9f3857

Browse files
feat: conferences summary endpoint + indexes (Phase 0 + Phase 1 of #20) (#24)
* feat: add GET /v1/conferences/summary endpoint for dashboard charts Returns conference counts grouped by day, bucketed into success/warning/error/ongoing. This replaces the pattern of downloading all conferences to the browser and aggregating client-side. Observed benefit on local test data (2,573 conferences, 1.1 MB response): - Full list: 1,139,816 bytes - Summary: 854 bytes (1335x smaller) Supports appId, created_at_gte, created_at_lte query params. Handles both Python 3.8 native ISO format and the Z suffix that JavaScript toISOString produces. Implementation uses a CASE expression to classify each conference as ongoing/error/warning/success (mutually exclusive stacks the chart renders), then groups by (day, status). Works around Postgres's restriction that correlated subqueries (Exists) aren't allowed inside COUNT(...) FILTER clauses. Phase 1 of #20. * feat: add Phase 0 indexes for dashboard aggregation queries Prerequisite for the summary endpoint (Phase 1) to scale on production. Without these, the aggregation query still runs a full table scan on 42K+ conferences. - conference(app, -created_at): filter + order for summary endpoint - issue(conference, type): speeds up the Exists subqueries that classify conferences as has_error / has_warning - app_genericevent(app, -created_at): future event summary endpoints and cleanup queries Per the phased plan in #20 and #15. * fix: declare dashboard indexes in model Meta.indexes Addresses review feedback. The indexes were only defined in the migration's AddIndex operations; the models didn't declare them in Meta.indexes. That created a state mismatch where future makemigrations runs would generate RemoveIndex operations for these performance-critical indexes during unrelated schema work. Verified: `python manage.py makemigrations --dry-run` now reports "No changes detected" after the model updates.
1 parent 486534d commit d9f3857

6 files changed

Lines changed: 154 additions & 0 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
"""
6+
Phase 0 indexes for the dashboard aggregation work (#20).
7+
8+
Targets the queries run by the new /v1/conferences/summary endpoint
9+
and similar time-range dashboard queries:
10+
11+
- conference(app_id, created_at): filter + GROUP BY for the summary
12+
- issue(conference_id, type): speeds up the EXISTS subqueries that
13+
classify conferences as has_error / has_warning
14+
- app_genericevent(app_id, created_at): future summary endpoints that
15+
group events by day (also helps cleanup_stale_conferences)
16+
"""
17+
18+
dependencies = [
19+
('app', '0003_conference_unique_together'),
20+
]
21+
22+
operations = [
23+
migrations.AddIndex(
24+
model_name='conference',
25+
index=models.Index(
26+
fields=['app', '-created_at'],
27+
name='idx_conf_app_created',
28+
),
29+
),
30+
migrations.AddIndex(
31+
model_name='issue',
32+
index=models.Index(
33+
fields=['conference', 'type'],
34+
name='idx_issue_conf_type',
35+
),
36+
),
37+
migrations.AddIndex(
38+
model_name='genericevent',
39+
index=models.Index(
40+
fields=['app', '-created_at'],
41+
name='idx_genevent_app_created',
42+
),
43+
),
44+
]

app/models/conference.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ class Conference(BaseModel):
3838
class Meta:
3939
db_table = 'conference'
4040
unique_together = (('conference_id', 'app'),)
41+
indexes = [
42+
models.Index(fields=['app', '-created_at'], name='idx_conf_app_created'),
43+
]
4144

4245
cache_keys = (
4346
sorted(('id',)),

app/models/generic_event.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ class GenericEvent(BaseModel):
3232
data: the data of the event
3333
"""
3434

35+
class Meta:
36+
indexes = [
37+
models.Index(fields=['app', '-created_at'], name='idx_genevent_app_created'),
38+
]
39+
3540
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
3641
conference = models.ForeignKey('Conference', on_delete=models.CASCADE, null=False, related_name='events')
3742
participant = models.ForeignKey('Participant', on_delete=models.CASCADE, null=False, related_name='events')

app/models/issue.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ class Issue(BaseModel):
8686
"""
8787
TYPES_OF_ISSUES = TYPES_OF_ISSUES
8888

89+
class Meta:
90+
indexes = [
91+
models.Index(fields=['conference', 'type'], name='idx_issue_conf_type'),
92+
]
93+
8994
cache_keys = (
9095
sorted(('id',)),
9196
)

app/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .views.apps_view import AppsView
66
from .views.browser_event_view import BrowserEventView
77
from .views.conference_view import ConferencesView
8+
from .views.conference_summary_view import ConferenceSummaryView
89
from .views.connection_event_view import ConnectionEventView
910
from .views.connection_view import ConnectionView
1011
from .views.issue_view import IssueView
@@ -55,6 +56,7 @@
5556
path('apps/<uuid:pk>/reset-key', AppsResetApiKeyView.as_view(), name='app-reset-key'),
5657

5758
path('conferences', ConferencesView.as_view(), name='conferences'),
59+
path('conferences/summary', ConferenceSummaryView.as_view(), name='conferences-summary'),
5860
path('conferences/<uuid:pk>', ConferencesView.as_view(), name='conference'),
5961
path('conferences/<uuid:pk>/events', ConferenceEventsView.as_view(), name='conference-events'),
6062
path('conferences/<uuid:pk>/graphs', ConferenceGraphView.as_view(), name='conference-graphs'),
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import datetime
2+
from collections import defaultdict
3+
4+
from django.core.exceptions import ValidationError
5+
from django.db.models import Case, CharField, Count, Exists, OuterRef, Value, When
6+
from django.db.models.functions import TruncDate
7+
8+
from ..errors import INVALID_PARAMETERS, MISSING_PARAMETERS, PMError
9+
from ..utils import JSONHttpResponse
10+
from ..models.conference import Conference
11+
from ..models.issue import Issue
12+
from .generic_view import GenericView
13+
14+
15+
def _parse_iso(value):
16+
# Python 3.8 fromisoformat doesn't accept the Z suffix that JS toISOString produces.
17+
if value.endswith('Z'):
18+
value = value[:-1] + '+00:00'
19+
return datetime.datetime.fromisoformat(value)
20+
21+
22+
class ConferenceSummaryView(GenericView):
23+
"""
24+
Returns conference counts grouped by day, with each day's conferences
25+
bucketed into success/warning/error/ongoing. Replaces the pattern of
26+
downloading all conferences to the browser and aggregating client-side.
27+
"""
28+
29+
@classmethod
30+
def get(cls, request):
31+
app_id = request.GET.get('appId')
32+
if not app_id:
33+
raise PMError(status=400, app_error=MISSING_PARAMETERS)
34+
35+
filters = {'app_id': app_id, 'is_active': True}
36+
37+
created_at_gte = request.GET.get('created_at_gte')
38+
if created_at_gte:
39+
try:
40+
filters['created_at__gte'] = _parse_iso(created_at_gte)
41+
except ValueError:
42+
raise PMError(status=400, app_error=INVALID_PARAMETERS)
43+
44+
created_at_lte = request.GET.get('created_at_lte')
45+
if created_at_lte:
46+
try:
47+
filters['created_at__lte'] = _parse_iso(created_at_lte)
48+
except ValueError:
49+
raise PMError(status=400, app_error=INVALID_PARAMETERS)
50+
51+
try:
52+
rows = (Conference.objects
53+
.filter(**filters)
54+
.annotate(
55+
day=TruncDate('created_at'),
56+
has_error=Exists(
57+
Issue.objects.filter(conference=OuterRef('pk'), type='e', is_active=True)
58+
),
59+
has_warning=Exists(
60+
Issue.objects.filter(conference=OuterRef('pk'), type='w', is_active=True)
61+
),
62+
)
63+
.annotate(
64+
status=Case(
65+
When(ongoing=True, then=Value('ongoing')),
66+
When(has_error=True, then=Value('error')),
67+
When(has_warning=True, then=Value('warning')),
68+
default=Value('success'),
69+
output_field=CharField(),
70+
),
71+
)
72+
.values('day', 'status')
73+
.annotate(count=Count('id'))
74+
.order_by('day'))
75+
except ValidationError:
76+
raise PMError(status=400, app_error=INVALID_PARAMETERS)
77+
78+
buckets = defaultdict(lambda: {'success': 0, 'warning': 0, 'error': 0, 'ongoing': 0})
79+
for row in rows:
80+
day_key = row['day'].isoformat() if row['day'] else None
81+
buckets[day_key][row['status']] = row['count']
82+
83+
data = [
84+
{
85+
'date': day,
86+
'success': counts['success'],
87+
'warning': counts['warning'],
88+
'error': counts['error'],
89+
'ongoing': counts['ongoing'],
90+
'total': counts['success'] + counts['warning'] + counts['error'] + counts['ongoing'],
91+
}
92+
for day, counts in sorted(buckets.items(), key=lambda x: x[0] or '')
93+
]
94+
95+
return JSONHttpResponse({'data': data})

0 commit comments

Comments
 (0)