Skip to content

Commit 72a277f

Browse files
authored
Merge pull request #1090 from marksweb/fix/1079
fix: resolve N+1 issue getting questions & answers
2 parents bd02d30 + 152c0d1 commit 72a277f

File tree

3 files changed

+101
-16
lines changed

3 files changed

+101
-16
lines changed

applications/views.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ def apply(request, page_url):
2222
if not event:
2323
raise Http404
2424
elif isinstance(event, tuple):
25-
return render(request, "applications/event_not_live.html", {"city": event[0], "past": event[1]})
25+
return render(
26+
request,
27+
"applications/event_not_live.html",
28+
{"city": event[0], "past": event[1]},
29+
)
2630

2731
form_obj = Form.objects.filter(event=event).first()
2832
if form_obj is None:
@@ -39,7 +43,10 @@ def apply(request, page_url):
3943

4044
if form.is_valid():
4145
form.save()
42-
messages.success(request, _("Yay! Your application has been saved. You'll hear from us soon!"))
46+
messages.success(
47+
request,
48+
_("Yay! Your application has been saved. You'll hear from us soon!"),
49+
)
4350

4451
return render(
4552
request,
@@ -117,7 +124,12 @@ def applications_csv(request, page_url):
117124
response = HttpResponse(content_type="text/csv")
118125
response["Content-Disposition"] = f'attachment; filename="{page_url}.csv"'
119126
writer = csv.writer(response)
120-
csv_header = [_("Application Number"), _("Application State"), _("RSVP Status"), _("Average Score")]
127+
csv_header = [
128+
_("Application Number"),
129+
_("Application State"),
130+
_("RSVP Status"),
131+
_("Average Score"),
132+
]
121133
question_set = event.form.question_set
122134

123135
question_titles = question_set.values_list("title", flat=True)
@@ -149,6 +161,8 @@ def application_detail(request, page_url, app_number):
149161
.order_by("-created")
150162
.first()
151163
)
164+
# Get the related answers and questions to avoid N+1 queries in the template.
165+
answers = application.answer_set.select_related("question").all()
152166
try:
153167
score = Score.objects.get(user=request.user, application=application)
154168
except Score.DoesNotExist:
@@ -177,13 +191,14 @@ def application_detail(request, page_url, app_number):
177191
request,
178192
"applications/application_detail.html",
179193
{
180-
"event": event,
194+
"answers": answers,
181195
"application": application,
196+
"event": event,
182197
"form": application.form,
198+
"menu": get_organiser_menu(page_url),
199+
"score_form": score_form,
183200
"scores": all_scores,
184201
"user_score": score,
185-
"score_form": score_form,
186-
"menu": get_organiser_menu(page_url),
187202
},
188203
)
189204

@@ -217,7 +232,11 @@ def compose_email(request, page_url, email_id=None):
217232
form_obj = get_object_or_404(Form, event=event)
218233
emailmsg = None if not email_id else get_object_or_404(Email, form__event=event, id=email_id)
219234

220-
form = EmailForm(request.POST or None, instance=emailmsg, initial={"author": request.user, "form": form_obj})
235+
form = EmailForm(
236+
request.POST or None,
237+
instance=emailmsg,
238+
initial={"author": request.user, "form": form_obj},
239+
)
221240
if form.is_valid() and request.method == "POST":
222241
obj = form.save(commit=False)
223242
obj.author = request.user
@@ -293,7 +312,11 @@ def rsvp(request, page_url, code):
293312
if not event:
294313
raise Http404
295314
elif isinstance(event, tuple):
296-
return render(request, "applications/event_not_live.html", {"city": event[0], "past": event[1]})
315+
return render(
316+
request,
317+
"applications/event_not_live.html",
318+
{"city": event[0], "past": event[1]},
319+
)
297320

298321
application, rsvp = Application.get_by_rsvp_code(code, event)
299322
if not application:
@@ -337,4 +360,8 @@ def rsvp(request, page_url, code):
337360

338361
menu = EventPageMenu.objects.filter(event=event)
339362

340-
return render(request, "applications/rsvp.html", {"event": event, "menu": menu, "message": message})
363+
return render(
364+
request,
365+
"applications/rsvp.html",
366+
{"event": event, "menu": menu, "message": message},
367+
)

templates/applications/application_detail.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ <h3 class="page-header">Application #{{ application.number }} for {{ event.page_
1515

1616
<div class="row">
1717
<div class="col-md-7 applications">
18-
{% for answer in application.answer_set.all %}
18+
{% for answer in answers %}
1919
<div class="answer">
2020
<p class="question">{{ answer.question.title|safe }}</p>
2121
<p>{{ answer.answer|urlize|linebreaks }}</p>

tests/applications/views/test_applications.py

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.urls import reverse
55
from django.utils import translation
66

7-
from applications.models import Application, Score
7+
from applications.models import Answer, Application, Question, Score
88
from applications.views import application_list
99

1010

@@ -203,12 +203,16 @@ def test_changing_application_status_errors(client, admin_client, future_event,
203203

204204
# lack of state parameter
205205
resp = admin_client.post(
206-
reverse("applications:change_state", args=[future_event.page_url]), {"application": application_submitted.id}
206+
reverse("applications:change_state", args=[future_event.page_url]),
207+
{"application": application_submitted.id},
207208
)
208209
assert "error" in resp.json()
209210

210211
# lack of application parameter
211-
resp = admin_client.post(reverse("applications:change_state", args=[future_event.page_url]), {"state": "accepted"})
212+
resp = admin_client.post(
213+
reverse("applications:change_state", args=[future_event.page_url]),
214+
{"state": "accepted"},
215+
)
212216
assert "error" in resp.json()
213217

214218

@@ -222,13 +226,15 @@ def test_changing_application_rsvp_errors(client, admin_client, future_event, ap
222226

223227
# lack of rsvp_status parameter
224228
resp = admin_client.post(
225-
reverse("applications:change_rsvp", args=[future_event.page_url]), {"application": application_submitted.id}
229+
reverse("applications:change_rsvp", args=[future_event.page_url]),
230+
{"application": application_submitted.id},
226231
)
227232
assert "error" in resp.json()
228233

229234
# lack of application parameter
230235
resp = admin_client.post(
231-
reverse("applications:change_rsvp", args=[future_event.page_url]), {"rsvp_status": Application.RSVP_YES}
236+
reverse("applications:change_rsvp", args=[future_event.page_url]),
237+
{"rsvp_status": Application.RSVP_YES},
232238
)
233239
assert "error" in resp.json()
234240

@@ -238,7 +244,10 @@ def test_changing_application_status_in_bulk(admin_client, future_event, applica
238244
assert application_rejected.state == "rejected"
239245
resp = admin_client.post(
240246
reverse("applications:change_state", args=[future_event.page_url]),
241-
{"state": "accepted", "application": [application_submitted.id, application_rejected.id]},
247+
{
248+
"state": "accepted",
249+
"application": [application_submitted.id, application_rejected.id],
250+
},
242251
)
243252
assert resp.status_code == 200
244253
application_submitted = Application.objects.get(id=application_submitted.id)
@@ -258,3 +267,52 @@ def test_application_scores_is_queried_once(admin_client, future_event, scored_a
258267

259268
# The first query is for the annotation in get_applications_for_event, the second is for the scores themselves
260269
assert len(score_queries) == 2
270+
271+
272+
def test_application_detail_queries_optimized(admin_client, future_event, application_submitted, future_event_form):
273+
"""Regression test to ensure application_detail view doesn't have N+1 query problem with answers/questions."""
274+
# Create multiple questions and answers for the application
275+
questions = []
276+
for i in range(5):
277+
question = Question.objects.create(
278+
form=future_event_form,
279+
title=f"Test Question {i}",
280+
question_type="text",
281+
order=i,
282+
)
283+
questions.append(question)
284+
Answer.objects.create(
285+
application=application_submitted,
286+
question=question,
287+
answer=f"Test Answer {i}",
288+
)
289+
290+
application_detail_url = reverse(
291+
"applications:application_detail",
292+
kwargs={
293+
"page_url": future_event.page_url,
294+
"app_number": application_submitted.number,
295+
},
296+
)
297+
298+
with CaptureQueriesContext(connection) as queries:
299+
resp = admin_client.get(application_detail_url)
300+
assert resp.status_code == 200
301+
302+
# Check that we're not making separate queries for each question
303+
question_table_name = Question._meta.db_table
304+
answer_table_name = Answer._meta.db_table
305+
306+
question_queries = [q for q in queries.captured_queries if question_table_name in q["sql"]]
307+
answer_queries = [q for q in queries.captured_queries if answer_table_name in q["sql"]]
308+
309+
# With select_related, we should have only 1 query that joins answers and questions
310+
# The query should join both tables, so it will appear in both lists
311+
# We're checking that there's at most 1 query involving answers (with joined questions)
312+
assert len(answer_queries) <= 1, f"Expected at most 1 answer query, got {len(answer_queries)}"
313+
314+
# Verify that if there are question queries, they're part of the joined query with answers
315+
# There should not be 5 separate queries (one per question)
316+
assert len(question_queries) <= 1, (
317+
f"Expected at most 1 question query (joined with answers), got {len(question_queries)}"
318+
)

0 commit comments

Comments
 (0)