Skip to content

Commit c9ce2c9

Browse files
mfosterwclaude
andauthored
Change bill creation flow (#298)
* Refactor bill creation flow and make submit task nullable - Move submit task creation into Bill.save() override instead of the BillManager context manager, keeping the logic self-contained in the model - Make _submit_task nullable; draft bills no longer get a task on creation — _schedule_submit_task() is called from save() for non-draft bills and from publish() when a draft is opened for voting - Simplify BillManager.create_from_github and remove TaskFactory from tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Moved logic from view to manager --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d2eb8a9 commit c9ce2c9

7 files changed

Lines changed: 128 additions & 165 deletions

File tree

democrasite/webiscite/managers.py

Lines changed: 33 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
1-
import json
2-
from collections.abc import Iterator
3-
from contextlib import contextmanager
4-
from logging import getLogger
1+
from logging import WARNING
52
from typing import TYPE_CHECKING
63
from typing import Any
74

8-
from django.conf import settings
5+
import requests
96
from django.db import models
10-
from django.utils import timezone
11-
from django_celery_beat.models import IntervalSchedule
12-
from django_celery_beat.models import PeriodicTask
137

148
from democrasite.users.models import User
159

1610
from .constitution import is_constitutional
1711

18-
logger = getLogger(__name__)
19-
2012
if TYPE_CHECKING:
2113
from .models import Bill # pragma: no cover
2214
from .models import PullRequest # pragma: no cover
@@ -102,85 +94,49 @@ def annotate_user_vote(
10294

10395
def create_from_github(
10496
self,
105-
title: str,
106-
body: str,
107-
author: User,
108-
diff_text: str,
10997
pull_request: "PullRequest",
110-
) -> T:
98+
desc: str,
99+
author_uid: str,
100+
) -> T | None:
111101
"""Validate and create a :class:`~democrasite.webiscite.models.Bill` from a
112102
GitHub pull request
113103
114104
Args:
115-
title: The title of the pull request
116-
body: The body of the pull request
117-
author: The user who created the pull request
118-
diff_text: The text of the diff of the pull request
119105
pull_request: The pull request instance to associate with the bill
106+
desc: The description of the bill (pull request body)
107+
author_uid: The GitHub UID of the pull request author
120108
121109
Returns:
122-
The new bill instance
110+
The new bill instance, or None if the author has no linked account
123111
"""
124-
draft = pull_request.draft
125-
with self._create_submit_task(enabled=not draft) as submit_task:
126-
self._bill: Bill = self.model(
127-
name=title,
128-
description=body,
129-
author=author,
130-
pull_request=pull_request,
131-
status=self.model.Status.DRAFT if draft else self.model.Status.OPEN,
132-
constitutional=bool(is_constitutional(diff_text)),
133-
_submit_task=submit_task,
112+
try:
113+
author = User.objects.filter(socialaccount__provider="github").get(
114+
socialaccount__uid=author_uid
134115
)
135-
self._bill.full_clean()
136-
self._bill.save()
137-
bill = self._bill
138-
bill.log("Created")
139-
140-
return bill
141-
142-
@contextmanager
143-
def _create_submit_task(self, *, enabled: bool = True) -> Iterator[PeriodicTask]:
144-
"""Schedule a task to submit this bill for voting
116+
except User.DoesNotExist:
117+
# If the creator of the pull request does not have a linked account,
118+
# a Bill cannot be created and the pr is ignored.
119+
pull_request.log("No bill created (user does not exist)", level=WARNING)
120+
return None
145121

146-
Args:
147-
enabled: Whether the task should be enabled immediately. Set to False
148-
for draft bills whose voting period hasn't started yet.
122+
diff_text = requests.get(pull_request.diff_url, timeout=10).text
123+
constitutional = bool(is_constitutional(diff_text))
149124

150-
Returns:
151-
The task that was scheduled
152-
"""
153-
# This might be better as a signal but I want to keep it localized
154-
voting_ends, __ = IntervalSchedule.objects.get_or_create(
155-
every=settings.WEBISCITE_VOTING_PERIOD, period=IntervalSchedule.DAYS
125+
status = (
126+
self.model.Status.DRAFT if pull_request.draft else self.model.Status.OPEN
156127
)
157128

158-
submit_task = PeriodicTask.objects.create(
159-
interval=voting_ends,
160-
name="bill_submit:temp",
161-
task="democrasite.webiscite.tasks.submit_bill",
162-
one_off=True,
163-
enabled=enabled,
164-
# If last_run_at is not set, the task will run relative to when the
165-
# scheduler started, not when it was created
166-
last_run_at=timezone.now(),
129+
bill = self.model(
130+
name=pull_request.title,
131+
description=desc,
132+
author=author,
133+
pull_request=pull_request,
134+
status=status,
135+
constitutional=constitutional,
167136
)
168-
try:
169-
yield submit_task
170-
finally:
171-
if not (
172-
hasattr(self, "_bill")
173-
and hasattr(self._bill, "id")
174-
and isinstance(self._bill.id, int)
175-
):
176-
raise AttributeError(
177-
"self._bill was not saved in the submit task context"
178-
)
179-
180-
submit_task.name = f"bill_submit:{self._bill.id}"
181-
submit_task.args = json.dumps([self._bill.id])
182-
submit_task.save()
183-
self._bill.log("Scheduled %s", submit_task.name)
184-
185-
# Attribute could be shared between model instances
186-
del self._bill
137+
138+
bill.full_clean()
139+
bill.save()
140+
bill.log("Created")
141+
142+
return bill
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 5.2.11 on 2026-02-22 17:54
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('django_celery_beat', '0019_alter_periodictasks_options'),
11+
('webiscite', '0006_remove_bill_unique_open_pull_request_and_more'),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name='bill',
17+
name='_submit_task',
18+
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='django_celery_beat.periodictask'),
19+
),
20+
]

democrasite/webiscite/models.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"""Models for the webiscite app"""
22

3+
import json
34
import logging
45

56
from django.conf import settings
67
from django.db import models
78
from django.urls import reverse
89
from django.utils import timezone
910
from django.utils.translation import gettext_lazy as _
11+
from django_celery_beat.models import IntervalSchedule
1012
from django_celery_beat.models import PeriodicTask
1113
from model_utils.models import TimeStampedModel
1214
from simple_history.models import HistoricalRecords
@@ -147,7 +149,9 @@ class Status(models.TextChoices):
147149

148150
# Automatic fields
149151
votes = models.ManyToManyField(User, through=Vote, related_name="votes", blank=True)
150-
_submit_task = models.OneToOneField(PeriodicTask, on_delete=models.PROTECT)
152+
_submit_task = models.OneToOneField(
153+
PeriodicTask, on_delete=models.PROTECT, null=True, blank=True
154+
)
151155

152156
history = HistoricalRecords()
153157

@@ -244,15 +248,39 @@ def user_supports(self, user: User) -> bool | None:
244248
else:
245249
return vote.support
246250

251+
def save(self, *args, **kwargs):
252+
created = self._state.adding
253+
super().save(*args, **kwargs)
254+
if created and self.status != self.Status.DRAFT:
255+
self._schedule_submit_task()
256+
257+
def _schedule_submit_task(self) -> None:
258+
"""Create a periodic task to submit this bill after the voting period."""
259+
260+
voting_ends, __ = IntervalSchedule.objects.get_or_create(
261+
every=settings.WEBISCITE_VOTING_PERIOD, period=IntervalSchedule.DAYS
262+
)
263+
self._submit_task = PeriodicTask.objects.create(
264+
interval=voting_ends,
265+
name=f"bill_submit:{self.id}",
266+
task="democrasite.webiscite.tasks.submit_bill",
267+
args=json.dumps([self.id]),
268+
one_off=True,
269+
last_run_at=timezone.now(),
270+
)
271+
super().save()
272+
self.log("Scheduled %s", self._submit_task.name)
273+
247274
def close(self) -> None:
248275
"""Close the bill and disable its submit task"""
249276
self.status = self.Status.CLOSED
250277
self.save()
251278
self.log("Closed")
252279

253-
self._submit_task.enabled = False
254-
self._submit_task.save()
255-
self.log("Submit task disabled")
280+
if self._submit_task is not None:
281+
self._submit_task.enabled = False
282+
self._submit_task.save()
283+
self.log("Submit task disabled")
256284

257285
def publish(self) -> None:
258286
"""Transition a draft bill to open, enabling voting and scheduling submission"""
@@ -261,10 +289,7 @@ def publish(self) -> None:
261289

262290
self.status = self.Status.OPEN
263291
self.save()
264-
265-
self._submit_task.enabled = True
266-
self._submit_task.last_run_at = timezone.now()
267-
self._submit_task.save()
292+
self._schedule_submit_task()
268293
self.log("Published")
269294

270295
def submit(self) -> None:

democrasite/webiscite/tests/factories.py

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
from typing import Any
22

33
import factory
4-
from django_celery_beat.models import IntervalSchedule
5-
from django_celery_beat.models import PeriodicTask
64

75
from democrasite.users.tests.factories import UserFactory
86
from democrasite.webiscite.models import Bill
@@ -25,18 +23,6 @@ class Meta:
2523
model = PullRequest
2624

2725

28-
class TaskFactory(factory.django.DjangoModelFactory[PeriodicTask]):
29-
interval = factory.LazyFunction(
30-
lambda: IntervalSchedule.objects.get_or_create(
31-
every=999, period=IntervalSchedule.DAYS
32-
)[0]
33-
)
34-
name = factory.Faker("text", max_nb_chars=50)
35-
36-
class Meta:
37-
model = PeriodicTask
38-
39-
4026
class BillFactory(factory.django.DjangoModelFactory[Bill]):
4127
name = factory.Faker("text", max_nb_chars=50)
4228
description = factory.Faker("paragraph")
@@ -45,9 +31,7 @@ class BillFactory(factory.django.DjangoModelFactory[Bill]):
4531
# Fields with defaults
4632
status = Bill.Status.OPEN
4733
constitutional = False
48-
_submit_task = factory.SubFactory(TaskFactory)
49-
# Currently yes_votes and no_votes are initialized as empty. If values are needed
50-
# for them, a post-generation hook can be written to generate and insert the users
34+
# _submit_task is created by Bill.save() for non-draft bills
5135

5236
class Meta:
5337
model = Bill

democrasite/webiscite/tests/test_models.py

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
from unittest.mock import patch
2+
13
import pytest
4+
from allauth.socialaccount.models import SocialAccount
25
from django.conf import settings
36
from django.db import IntegrityError
47
from factory.faker import faker
@@ -125,43 +128,43 @@ def test_annotate_user_vote(self, bill: Bill, user: User):
125128
bill.vote(user, support=False)
126129
assert Bill.objects.annotate_user_vote(user).first().user_vote is None
127130

128-
def test_create_from_github(self, user: User):
131+
@patch("requests.get")
132+
def test_create_from_github(self, mock_get, user: User):
133+
mock_get.return_value.text = ""
129134
pr = PullRequestFactory.create()
130-
bill = Bill.objects.create_from_github(
131-
pr.title,
132-
FAKE.text(),
133-
user,
134-
FAKE.text(),
135-
pr,
136-
)
135+
uid = SocialAccount.objects.create(
136+
user=user, provider="github", uid=FAKE.random_int()
137+
).uid
138+
139+
bill = Bill.objects.create_from_github(pr, FAKE.text(), uid)
137140

141+
assert bill is not None
138142
assert bill.pk is not None
143+
bill.refresh_from_db()
144+
assert bill._submit_task is not None # noqa: SLF001
139145
assert bill._submit_task.enabled is True # noqa: SLF001
140146

141-
def test_create_from_github_draft(self, user: User):
147+
@patch("requests.get")
148+
def test_create_from_github_draft(self, mock_get, user: User):
149+
mock_get.return_value.text = ""
142150
pr = PullRequestFactory.create(draft=True)
143-
bill = Bill.objects.create_from_github(
144-
pr.title,
145-
FAKE.text(),
146-
user,
147-
FAKE.text(),
148-
pr,
149-
)
151+
uid = SocialAccount.objects.create(
152+
user=user, provider="github", uid=FAKE.random_int()
153+
).uid
150154

155+
bill = Bill.objects.create_from_github(pr, FAKE.text(), uid)
156+
157+
assert bill is not None
151158
assert bill.pk is not None
152159
assert bill.status == Bill.Status.DRAFT
153-
assert bill._submit_task.enabled is False # noqa: SLF001
160+
assert bill._submit_task is None # noqa: SLF001
161+
162+
def test_create_from_github_no_user(self):
163+
pr = PullRequestFactory.create()
164+
165+
result = Bill.objects.create_from_github(pr, FAKE.text(), "nonexistent-uid")
154166

155-
def test__create_submit_task(self):
156-
# just hit the error branch of finally clause
157-
with (
158-
pytest.raises(
159-
AttributeError,
160-
match=r"self._bill was not saved in the submit task context",
161-
),
162-
Bill.objects._create_submit_task(), # noqa: SLF001
163-
):
164-
pass
167+
assert result is None
165168

166169

167170
class TestBill:
@@ -194,25 +197,27 @@ def test_get_vote_url(self, bill: Bill):
194197
assert bill.get_vote_url() == f"/bills/{bill.id}/vote/"
195198

196199
def test_close(self, bill: Bill):
200+
assert bill._submit_task is not None # noqa: SLF001
197201
assert bill._submit_task.enabled is True # noqa: SLF001
198202

199203
bill.close()
200204

201205
bill.refresh_from_db()
202206
assert bill.status == Bill.Status.CLOSED
207+
assert bill._submit_task is not None # noqa: SLF001
203208
assert bill._submit_task.enabled is False # noqa: SLF001
204209

205210

206211
class TestBillPublish:
207212
def test_publish(self):
208213
bill = BillFactory.create(status=Bill.Status.DRAFT)
209-
bill._submit_task.enabled = False # noqa: SLF001
210-
bill._submit_task.save() # noqa: SLF001
214+
assert bill._submit_task is None # noqa: SLF001
211215

212216
bill.publish()
213217

214218
bill.refresh_from_db()
215219
assert bill.status == Bill.Status.OPEN
220+
assert bill._submit_task is not None # noqa: SLF001
216221
assert bill._submit_task.enabled is True # noqa: SLF001
217222
assert bill._submit_task.last_run_at is not None # noqa: SLF001
218223

0 commit comments

Comments
 (0)