Skip to content

Commit cba8b59

Browse files
authored
Improve bill annotations to reduce queries (#303)
* Improve bill annotations to reduce queries * Fix tests * Add type annotation back to manager create method * Remove duplicate code
1 parent c028eca commit cba8b59

7 files changed

Lines changed: 66 additions & 67 deletions

File tree

democrasite/templates/webiscite/snippets/vote.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-trigger="click" title="{% trans 'Log in to vote' %}"
2222
{% endif %}
2323
{% endif %}>
24-
✓ {% trans 'Yes' %}: <span class="num-yes-votes" id="num-vote-yes-{{ bill.id }}">{{ bill.yes_votes.count }}</span>
24+
✓ {% trans 'Yes' %}: <span class="num-yes-votes" id="num-vote-yes-{{ bill.id }}">{{ bill.yes_count }}</span>
2525
</a>
2626

2727
<a href="javascript:;"
@@ -35,5 +35,5 @@
3535
data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-trigger="click" title="{% trans 'Log in to vote' %}"
3636
{% endif %}
3737
{% endif %}>
38-
X {% trans 'No' %}: <span class="num-no-votes" id="num-vote-no-{{ bill.id }}">{{ bill.no_votes.count }}</span>
38+
X {% trans 'No' %}: <span class="num-no-votes" id="num-vote-no-{{ bill.id }}">{{ bill.no_count }}</span>
3939
</a>

democrasite/webiscite/api/views.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def has_object_permission(self, request, view, obj: Bill) -> bool:
5353
)
5454
class BillViewSet(RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericViewSet):
5555
serializer_class = BillSerializer
56-
queryset = Bill.objects.select_related("author", "pull_request")
56+
queryset = Bill.objects.all()
5757
permission_classes = [IsAuthorOrReadOnly]
5858

5959
def get_serializer_context(self):
@@ -84,8 +84,7 @@ def vote(self, request: Request, pk):
8484
except ClosedBillVoteError as err:
8585
raise PermissionDenied(str(err)) from err
8686

87-
return Response(
88-
{"yes_votes": bill.yes_votes.count(), "no_votes": bill.no_votes.count()}
89-
)
87+
bill = Bill.objects.get(pk=pk)
88+
return Response({"yes_votes": bill.yes_count, "no_votes": bill.no_count})
9089

9190
# TODO: Prefetch bill list votes

democrasite/webiscite/managers.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,19 @@ def create_from_github(self, pr: dict[str, Any]) -> T:
4747

4848
class BillManager[T](models.Manager):
4949
def get_queryset(self):
50-
"""Return a queryset with pull_request pre-fetched and vote percentages added.
50+
"""Return a queryset with related models pre-fetched and vote amounts added.
5151
52-
All Bill querysets include ``total_votes``, ``yes_percent``, and
53-
``no_percent``
54-
annotations, and are ordered by creation date.
52+
All Bill querysets include ``total_votes``, ``yes_count``, ``yes_percent``,
53+
``no_count`` and ``no_percent`` annotations, prefetch pull_request and author,
54+
and are ordered by creation date.
5555
"""
5656
return (
5757
super()
5858
.get_queryset()
59-
.select_related("pull_request")
59+
.select_related("pull_request", "author")
6060
.annotate(
6161
total_votes=models.Count("vote"),
62+
yes_count=models.Count("vote", filter=models.Q(vote__support=True)),
6263
yes_percent=models.Case(
6364
models.When(
6465
models.Q(total_votes__gt=0),
@@ -69,6 +70,7 @@ def get_queryset(self):
6970
default=models.Value(0),
7071
output_field=models.FloatField(),
7172
),
73+
no_count=models.Count("vote", filter=models.Q(vote__support=False)),
7274
no_percent=models.Case(
7375
models.When(
7476
models.Q(total_votes__gt=0),
@@ -109,10 +111,7 @@ def annotate_user_vote(
109111
)
110112

111113
def create_from_github(
112-
self,
113-
pull_request: "PullRequest",
114-
desc: str,
115-
author_uid: str,
114+
self, pull_request: "PullRequest", desc: str, author_uid: str
116115
) -> T | None:
117116
"""Validate and create a :class:`~democrasite.webiscite.models.Bill` from a
118117
GitHub pull request

democrasite/webiscite/models.py

Lines changed: 38 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,13 @@ class Status(models.TextChoices):
158158

159159
objects = BillManager()
160160

161+
# Annotations from default manager query
162+
total_votes = 0
163+
yes_count = 0
164+
yes_percent = 0
165+
no_count = 0
166+
no_percent = 0
167+
161168
class Meta:
162169
constraints = [
163170
models.UniqueConstraint(
@@ -184,6 +191,29 @@ def log(self, msg, *args, level=logging.INFO):
184191
)
185192
# f-string necessary to let string interpolation work in msg
186193

194+
def save(self, *args, **kwargs):
195+
created = self._state.adding
196+
super().save(*args, **kwargs)
197+
if created and self.status != self.Status.DRAFT:
198+
self._schedule_submit_task()
199+
200+
def _schedule_submit_task(self) -> None:
201+
"""Create a periodic task to submit this bill after the voting period."""
202+
203+
voting_ends, __ = IntervalSchedule.objects.get_or_create(
204+
every=settings.WEBISCITE_VOTING_PERIOD, period=IntervalSchedule.DAYS
205+
)
206+
self._submit_task = PeriodicTask.objects.create(
207+
interval=voting_ends,
208+
name=f"bill_submit:{self.id}",
209+
task="democrasite.webiscite.tasks.submit_bill",
210+
args=json.dumps([self.id]),
211+
one_off=True,
212+
last_run_at=timezone.now(),
213+
)
214+
super().save()
215+
self.log("Scheduled %s", self._submit_task.name)
216+
187217
def get_absolute_url(self) -> str:
188218
"""Returns URL to view this Bill instance"""
189219
return reverse("webiscite:bill-detail", kwargs={"pk": self.id})
@@ -196,20 +226,12 @@ def get_vote_url(self) -> str:
196226
"""Returns URL for the current user to vote on this Bill instance"""
197227
return reverse("webiscite:bill-vote", kwargs={"pk": self.id})
198228

199-
@property
200-
def yes_votes(self) -> models.QuerySet[User]:
201-
return self.votes.filter(vote__support=True)
202-
203-
@property
204-
def no_votes(self) -> models.QuerySet[User]:
205-
return self.votes.filter(vote__support=False)
206-
207229
def vote(self, user: User, *, support: bool) -> None:
208230
"""Sets the given user's vote based on the support parameter
209231
210232
If the user already voted the way the method would set, their vote is
211-
removed from the bill (i.e. if ``user`` is in ``bill.yes_votes`` and support is
212-
``True``, ``user`` is removed from ``bill.yes_votes``)
233+
removed from the bill (i.e. if the user previously voted yes and support is
234+
``True``, their vote is removed)
213235
214236
Args:
215237
user (User): The user voting on the bill
@@ -255,34 +277,11 @@ def user_supports(self, user: User) -> bool | None:
255277
else:
256278
return vote.support
257279

258-
def save(self, *args, **kwargs):
259-
created = self._state.adding
260-
super().save(*args, **kwargs)
261-
if created and self.status != self.Status.DRAFT:
262-
self._schedule_submit_task()
263-
264-
def _schedule_submit_task(self) -> None:
265-
"""Create a periodic task to submit this bill after the voting period."""
266-
267-
voting_ends, __ = IntervalSchedule.objects.get_or_create(
268-
every=settings.WEBISCITE_VOTING_PERIOD, period=IntervalSchedule.DAYS
269-
)
270-
self._submit_task = PeriodicTask.objects.create(
271-
interval=voting_ends,
272-
name=f"bill_submit:{self.id}",
273-
task="democrasite.webiscite.tasks.submit_bill",
274-
args=json.dumps([self.id]),
275-
one_off=True,
276-
last_run_at=timezone.now(),
277-
)
278-
super().save()
279-
self.log("Scheduled %s", self._submit_task.name)
280-
281280
def close(self, status: "Bill.Status" = Status.CLOSED) -> None:
282281
"""Close the bill and disable its submit task"""
283282
self.status = status
284283
self.save()
285-
self.log("Closed")
284+
self.log(status)
286285

287286
if self._submit_task is not None:
288287
self._submit_task.enabled = False
@@ -310,20 +309,18 @@ def submit(self) -> None:
310309
self.save()
311310

312311
def _check_approval(self) -> "Bill.Status":
313-
total_votes = self.votes.count()
314-
if total_votes < settings.WEBISCITE_MINIMUM_QUORUM:
312+
if self.total_votes < settings.WEBISCITE_MINIMUM_QUORUM:
315313
self.log("Rejected due to insufficient votes")
316314
return self.Status.FAILED
317315

318-
approval = self.yes_votes.count() / total_votes
319316
if self.constitutional:
320-
approved = approval > settings.WEBISCITE_SUPERMAJORITY
317+
approved = self.yes_percent / 100 > settings.WEBISCITE_SUPERMAJORITY
321318
else:
322-
approved = approval > settings.WEBISCITE_NORMAL_MAJORITY
319+
approved = self.yes_percent / 100 > settings.WEBISCITE_NORMAL_MAJORITY
323320

324321
if not approved:
325-
self.log("Rejected with %s%% approval", approval * 100)
322+
self.log("Rejected with %s%% approval", self.yes_percent)
326323
return self.Status.REJECTED
327324

328-
self.log("Approved with %s%% approval", approval * 100)
325+
self.log("Approved with %s%% approval", self.yes_percent)
329326
return self.Status.APPROVED

democrasite/webiscite/tests/api/test_views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def test_vote(self, bill: Bill, user: User, api_client: APIClient):
115115
assert response.status_code == HTTPStatus.OK
116116
assert response.data["yes_votes"] == 1
117117
assert response.data["no_votes"] == 0
118-
assert bill.yes_votes.filter(pk=user.pk).exists()
118+
assert bill.votes.filter(pk=user.pk).exists()
119119

120120
response = api_client.post(url, {"support": False})
121121

democrasite/webiscite/tests/test_models.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -240,27 +240,27 @@ def test_publish_not_draft(self, bill: Bill):
240240
class TestBillVote:
241241
def test_bill_vote_yes_toggle(self, bill: Bill, user: User):
242242
bill.vote(user, support=True)
243-
assert bill.yes_votes.filter(pk=user.id).exists()
243+
assert bill.votes.filter(pk=user.id, vote__support=True).exists()
244244
bill.vote(user, support=True)
245-
assert not bill.yes_votes.filter(pk=user.id).exists()
245+
assert not bill.votes.filter(pk=user.id, vote__support=True).exists()
246246

247247
def test_bill_vote_no_toggle(self, bill: Bill, user: User):
248248
bill.vote(user, support=False)
249-
assert bill.no_votes.filter(pk=user.id).exists()
249+
assert bill.votes.filter(pk=user.id, vote__support=False).exists()
250250
bill.vote(user, support=False)
251-
assert not bill.no_votes.filter(pk=user.id).exists()
251+
assert not bill.votes.filter(pk=user.id, vote__support=False).exists()
252252

253253
def test_bill_vote_yes_to_no_switch(self, bill: Bill, user: User):
254254
bill.vote(user, support=True)
255255
bill.vote(user, support=False)
256-
assert not bill.yes_votes.filter(pk=user.id).exists()
257-
assert bill.no_votes.filter(pk=user.id).exists()
256+
assert not bill.votes.filter(pk=user.id, vote__support=True).exists()
257+
assert bill.votes.filter(pk=user.id, vote__support=False).exists()
258258

259259
def test_bill_vote_no_to_yes_switch(self, bill: Bill, user: User):
260260
bill.vote(user, support=False)
261261
bill.vote(user, support=True)
262-
assert not bill.no_votes.filter(pk=user.id).exists()
263-
assert bill.yes_votes.filter(pk=user.id).exists()
262+
assert not bill.votes.filter(pk=user.id, vote__support=False).exists()
263+
assert bill.votes.filter(pk=user.id, vote__support=True).exists()
264264

265265
def test_bill_not_open(self, user: User):
266266
bill = BillFactory.create(status=Bill.Status.CLOSED)
@@ -300,6 +300,7 @@ def test_bill_rejected(self, bill: Bill):
300300
Vote.objects.bulk_create(
301301
[Vote(bill=bill, user=voter, support=False) for voter in voters]
302302
)
303+
bill = Bill.objects.get(id=bill.id) # set annotations
303304

304305
bill.submit()
305306

@@ -311,6 +312,7 @@ def test_constitutional_bill_rejected(self):
311312
Vote.objects.bulk_create(
312313
[Vote(bill=bill, user=voter, support=False) for voter in voters]
313314
)
315+
bill = Bill.objects.get(id=bill.id) # set annotations
314316

315317
bill.submit()
316318

@@ -321,6 +323,7 @@ def test_bill_passed(self, bill: Bill):
321323
Vote.objects.bulk_create(
322324
[Vote(bill=bill, user=voter, support=True) for voter in voters]
323325
)
326+
bill = Bill.objects.get(id=bill.id) # set annotations
324327

325328
bill.submit()
326329

democrasite/webiscite/views.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,10 @@ def vote_view(request: http.HttpRequest, pk: int) -> http.HttpResponse:
163163
except ClosedBillVoteError as err:
164164
return http.HttpResponseForbidden(str(err))
165165

166+
bill = Bill.objects.get(pk=pk) # refresh vote annotations
166167
return http.JsonResponse(
167168
{
168-
"yes-votes": bill.yes_votes.count(),
169-
"no-votes": bill.no_votes.count(),
169+
"yes-votes": bill.yes_count,
170+
"no-votes": bill.no_count,
170171
}
171172
)

0 commit comments

Comments
 (0)