Skip to content

Commit f5362d4

Browse files
mfosterwclaude
andcommitted
Add draft bill support for draft GitHub pull requests
Draft PRs now create bills with a new DRAFT status that cannot be voted on or submitted. When a PR is marked ready for review, the bill is published and the voting period begins. Includes model changes, webhook handler, fixture data, and tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cea6514 commit f5362d4

10 files changed

Lines changed: 320 additions & 12 deletions

File tree

democrasite/templates/webiscite/bill_detail.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ <h2>{{ bill }}</h2>
1919
{% if bill.status != bill.Status.OPEN %}
2020
<p class="{% if bill.status == bill.Status.APPROVED %}
2121
text-success
22+
{% elif bill.status == bill.Status.DRAFT %}
23+
text-secondary
2224
{% else %}
2325
text-danger
2426
{% endif %}

democrasite/templates/webiscite/bill_list.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ <h5 class="card-title">{{ bill }}</h5>
2626
{% if bill.status != bill.Status.OPEN %}
2727
<p class="{% if bill.status == bill.Status.APPROVED %}
2828
text-success
29+
{% elif bill.status == bill.Status.DRAFT %}
30+
text-secondary
2931
{% else %}
3032
text-danger
3133
{% endif %}

democrasite/webiscite/fixtures/democrasite.json

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"created": "2024-02-25T17:26:54.006Z",
4444
"modified": "2024-02-25T17:26:54.012Z",
4545
"status": "open",
46+
"draft": false,
4647

4748
"title": "The Initial Act",
4849
"additions": 0,
@@ -59,6 +60,7 @@
5960
"created": "2024-02-25T17:26:54.006Z",
6061
"modified": "2024-02-25T17:26:54.012Z",
6162
"status": "open",
63+
"draft": false,
6264

6365
"title": "The Test Act",
6466
"additions": 1000,
@@ -75,6 +77,7 @@
7577
"created": "2024-02-25T17:26:54.006Z",
7678
"modified": "2024-02-25T17:26:54.012Z",
7779
"status": "open",
80+
"draft": false,
7881

7982
"title": "The Finished Act",
8083
"additions": 69,
@@ -97,6 +100,7 @@
97100
"diff_url": "https://github.com/mfosterw/cookiestocracy/pull/64/files",
98101
"author_name": "matthew",
99102
"status": "open",
103+
"draft": false,
100104
"sha": "123",
101105
"history_date": "2025-09-07T22:52:36.583Z",
102106
"history_change_reason": "",
@@ -117,6 +121,7 @@
117121
"diff_url": "https://github.com/mfosterw/cookiestocracy/pull/64/files",
118122
"author_name": "octocat",
119123
"status": "open",
124+
"draft": false,
120125
"sha": "1234",
121126
"history_date": "2025-09-07T22:52:36.583Z",
122127
"history_change_reason": "",
@@ -137,6 +142,7 @@
137142
"diff_url": "https://github.com/mfosterw/cookiestocracy/pull/64/files",
138143
"author_name": "dependabot",
139144
"status": "open",
145+
"draft": false,
140146
"sha": "12345",
141147
"history_date": "2025-09-07T22:52:36.583Z",
142148
"history_change_reason": "",
@@ -430,5 +436,90 @@
430436
"enabled": false,
431437
"date_changed": "2024-02-20T12:00:22.856Z"
432438
}
439+
},
440+
{
441+
"model": "webiscite.pullrequest",
442+
"pk": -4,
443+
"fields": {
444+
"created": "2026-02-16T12:00:00.000Z",
445+
"modified": "2026-02-16T12:00:00.000Z",
446+
"status": "open",
447+
"draft": true,
448+
449+
"title": "The Draft Act",
450+
"additions": 42,
451+
"deletions": 7,
452+
"diff_url": "https://github.com/mfosterw/cookiestocracy/pull/64.diff",
453+
"author_name": "matthew",
454+
"sha": "abc123"
455+
}
456+
},
457+
{
458+
"model": "webiscite.historicalpullrequest",
459+
"pk": 4,
460+
"fields": {
461+
"created": "2026-02-16T12:00:00.000Z",
462+
"modified": "2026-02-16T12:00:00.000Z",
463+
"number": -4,
464+
"title": "The Draft Act",
465+
"additions": 42,
466+
"deletions": 7,
467+
"diff_url": "https://github.com/mfosterw/cookiestocracy/pull/64.diff",
468+
"author_name": "matthew",
469+
"status": "open",
470+
"draft": true,
471+
"sha": "abc123",
472+
"history_date": "2026-02-16T12:00:00.000Z",
473+
"history_change_reason": "",
474+
"history_type": "+",
475+
"history_user": null
476+
}
477+
},
478+
{
479+
"model": "webiscite.bill",
480+
"pk": 7,
481+
"fields": {
482+
"created": "2026-02-16T12:00:00.000Z",
483+
"modified": "2026-02-16T12:00:00.000Z",
484+
"name": "The Draft Act",
485+
"description": "This bill is a draft. It was created from a draft pull request and cannot be voted on until the PR is marked ready for review.",
486+
"author": 1,
487+
"pull_request": -4,
488+
"status": "draft",
489+
"constitutional": false,
490+
"_submit_task": 7
491+
}
492+
},
493+
{
494+
"model": "webiscite.historicalbill",
495+
"pk": 7,
496+
"fields": {
497+
"id": 7,
498+
"created": "2026-02-16T12:00:00.000Z",
499+
"modified": "2026-02-16T12:00:00.000Z",
500+
"name": "The Draft Act",
501+
"description": "This bill is a draft. It was created from a draft pull request and cannot be voted on until the PR is marked ready for review.",
502+
"status": "draft",
503+
"constitutional": false,
504+
"author": 1,
505+
"pull_request": -4,
506+
"_submit_task": 7,
507+
"history_date": "2026-02-16T12:00:00.000Z",
508+
"history_change_reason": "",
509+
"history_type": "+",
510+
"history_user": null
511+
}
512+
},
513+
{
514+
"model": "django_celery_beat.periodictask",
515+
"pk": 7,
516+
"fields": {
517+
"name": "bill_submit:7",
518+
"task": "democrasite.webiscite.tasks.submit_bill",
519+
"interval": 1,
520+
"args": "[7]",
521+
"enabled": false,
522+
"date_changed": "2026-02-16T12:00:00.000Z"
523+
}
433524
}
434525
]

democrasite/webiscite/managers.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def create_from_github(self, pr: dict[str, Any]) -> T:
4545
"author_name": pr["user"]["login"],
4646
"status": pr["state"],
4747
"sha": pr["head"]["sha"],
48+
"draft": pr.get("draft", False),
4849
},
4950
)
5051

@@ -111,21 +112,23 @@ def create_from_github(
111112
GitHub pull request
112113
113114
Args:
114-
pr: The pull request data from the GitHub API
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
115118
diff_text: The text of the diff of the pull request
116119
pull_request: The pull request instance to associate with the bill
117120
118121
Returns:
119-
A tuple containing the new or update pull request and new bill instance, if
120-
applicable
122+
The new bill instance
121123
"""
122-
with self._create_submit_task() as submit_task:
124+
draft = pull_request.draft
125+
with self._create_submit_task(enabled=not draft) as submit_task:
123126
self._bill: Bill = self.model(
124127
name=title,
125128
description=body,
126129
author=author,
127130
pull_request=pull_request,
128-
status=self.model.Status.OPEN,
131+
status=self.model.Status.DRAFT if draft else self.model.Status.OPEN,
129132
constitutional=bool(is_constitutional(diff_text)),
130133
_submit_task=submit_task,
131134
)
@@ -137,9 +140,13 @@ def create_from_github(
137140
return bill
138141

139142
@contextmanager
140-
def _create_submit_task(self) -> Iterator[PeriodicTask]:
143+
def _create_submit_task(self, *, enabled: bool = True) -> Iterator[PeriodicTask]:
141144
"""Schedule a task to submit this bill for voting
142145
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.
149+
143150
Returns:
144151
The task that was scheduled
145152
"""
@@ -153,6 +160,7 @@ def _create_submit_task(self) -> Iterator[PeriodicTask]:
153160
name="bill_submit:temp",
154161
task="democrasite.webiscite.tasks.submit_bill",
155162
one_off=True,
163+
enabled=enabled,
156164
# If last_run_at is not set, the task will run relative to when the
157165
# scheduler started, not when it was created
158166
last_run_at=timezone.now(),
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Generated by Django 5.2.11 on 2026-02-16 23:17
2+
3+
from django.conf import settings
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', '0005_alter_bill_name_alter_historicalbill_name_and_more'),
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
]
14+
15+
operations = [
16+
migrations.RemoveConstraint(
17+
model_name='bill',
18+
name='unique_open_pull_request',
19+
),
20+
migrations.AddField(
21+
model_name='historicalpullrequest',
22+
name='draft',
23+
field=models.BooleanField(default=False, help_text='Whether the pull request is a draft on GitHub'),
24+
),
25+
migrations.AddField(
26+
model_name='pullrequest',
27+
name='draft',
28+
field=models.BooleanField(default=False, help_text='Whether the pull request is a draft on GitHub'),
29+
),
30+
migrations.AlterField(
31+
model_name='bill',
32+
name='status',
33+
field=models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('failed', 'Not Enough Votes'), ('closed', 'PR Closed')], default='open', help_text='The current status of the bill', max_length=10),
34+
),
35+
migrations.AlterField(
36+
model_name='historicalbill',
37+
name='status',
38+
field=models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('failed', 'Not Enough Votes'), ('closed', 'PR Closed')], default='open', help_text='The current status of the bill', max_length=10),
39+
),
40+
migrations.AddConstraint(
41+
model_name='bill',
42+
constraint=models.UniqueConstraint(condition=models.Q(('status__in', ['open', 'draft'])), fields=('pull_request',), name='unique_active_pull_request', violation_error_message='A Bill for this pull request is already active'),
43+
),
44+
]

democrasite/webiscite/models.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.conf import settings
66
from django.db import models
77
from django.urls import reverse
8+
from django.utils import timezone
89
from django.utils.translation import gettext_lazy as _
910
from django_celery_beat.models import PeriodicTask
1011
from model_utils.models import TimeStampedModel
@@ -32,6 +33,9 @@ class PullRequest(TimeStampedModel):
3233
diff_url = models.URLField(help_text=_("URL to the diff of the pull request"))
3334
# Store Github username of author even if they are not a user on the site
3435
author_name = models.CharField(max_length=100)
36+
draft = models.BooleanField(
37+
default=False, help_text=_("Whether the pull request is a draft on GitHub")
38+
)
3539
#:
3640
status = models.CharField(
3741
max_length=6,
@@ -72,7 +76,9 @@ def close(self) -> "Bill | None":
7276
self.save()
7377

7478
try:
75-
bill: Bill = self.bill_set.get(status=Bill.Status.OPEN)
79+
bill: Bill = self.bill_set.get(
80+
status__in=[Bill.Status.OPEN, Bill.Status.DRAFT]
81+
)
7682
except Bill.DoesNotExist:
7783
self.log("No open bill found")
7884
return None
@@ -119,6 +125,7 @@ class Status(models.TextChoices):
119125
120126
:meta private:"""
121127

128+
DRAFT = "draft", _("Draft")
122129
OPEN = "open", _("Open")
123130
APPROVED = "approved", _("Approved")
124131
REJECTED = "rejected", _("Rejected")
@@ -151,10 +158,10 @@ class Meta:
151158
models.UniqueConstraint(
152159
fields=("pull_request",),
153160
# Can't reference Bill.Status because Bill isn't defined yet
154-
condition=models.Q(status="open"),
155-
name="unique_open_pull_request",
161+
condition=models.Q(status__in=["open", "draft"]),
162+
name="unique_active_pull_request",
156163
violation_error_message=_(
157-
"A Bill for this pull request is already open"
164+
"A Bill for this pull request is already active"
158165
),
159166
),
160167
]
@@ -247,6 +254,19 @@ def close(self) -> None:
247254
self._submit_task.save()
248255
self.log("Submit task disabled")
249256

257+
def publish(self) -> None:
258+
"""Transition a draft bill to open, enabling voting and scheduling submission"""
259+
if self.status != self.Status.DRAFT:
260+
raise ValueError("Only draft bills can be published")
261+
262+
self.status = self.Status.OPEN
263+
self.save()
264+
265+
self._submit_task.enabled = True
266+
self._submit_task.last_run_at = timezone.now()
267+
self._submit_task.save()
268+
self.log("Published")
269+
250270
def submit(self) -> None:
251271
"""Check if the bill has enough votes to pass and update the status"""
252272
# Bill was closed before voting period ended

democrasite/webiscite/tests/factories.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class PullRequestFactory(factory.django.DjangoModelFactory[PullRequest]):
1616
title = factory.Faker("text", max_nb_chars=50)
1717
author_name = factory.Faker("user_name")
1818
status = "open"
19+
draft = False
1920
additions = factory.Faker("random_int")
2021
deletions = factory.Faker("random_int")
2122
sha = factory.Faker("pystr", min_chars=40, max_chars=40)
@@ -67,6 +68,7 @@ class GithubPullRequestFactory(factory.Factory[dict[str, Any]]):
6768
state = "open"
6869
additions = factory.SelfAttribute("bill.pull_request.additions")
6970
deletions = factory.SelfAttribute("bill.pull_request.deletions")
71+
draft = False
7072
diff_url = "" # Keep blank so request.get raises an error
7173

7274
class Meta:

0 commit comments

Comments
 (0)