From f5362d473ffc80d1d7cfb5f804dcd78c0865ee5e Mon Sep 17 00:00:00 2001 From: Matthew Foster Walsh <15671892+mfosterw@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:39:53 -0700 Subject: [PATCH 1/2] 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 --- .../templates/webiscite/bill_detail.html | 2 + .../templates/webiscite/bill_list.html | 2 + .../webiscite/fixtures/democrasite.json | 91 +++++++++++++++++++ democrasite/webiscite/managers.py | 20 ++-- ..._bill_unique_open_pull_request_and_more.py | 44 +++++++++ democrasite/webiscite/models.py | 28 +++++- democrasite/webiscite/tests/factories.py | 2 + democrasite/webiscite/tests/test_models.py | 66 +++++++++++++- democrasite/webiscite/tests/test_webhooks.py | 49 ++++++++++ democrasite/webiscite/webhooks.py | 28 ++++++ 10 files changed, 320 insertions(+), 12 deletions(-) create mode 100644 democrasite/webiscite/migrations/0006_remove_bill_unique_open_pull_request_and_more.py diff --git a/democrasite/templates/webiscite/bill_detail.html b/democrasite/templates/webiscite/bill_detail.html index cce6b904..b9d52fbd 100644 --- a/democrasite/templates/webiscite/bill_detail.html +++ b/democrasite/templates/webiscite/bill_detail.html @@ -19,6 +19,8 @@

{{ bill }}

{% if bill.status != bill.Status.OPEN %}

{{ bill }} {% if bill.status != bill.Status.OPEN %}

T: "author_name": pr["user"]["login"], "status": pr["state"], "sha": pr["head"]["sha"], + "draft": pr.get("draft", False), }, ) @@ -111,21 +112,23 @@ def create_from_github( GitHub pull request Args: - pr: The pull request data from the GitHub API + title: The title of the pull request + body: The body of the pull request + author: The user who created the pull request diff_text: The text of the diff of the pull request pull_request: The pull request instance to associate with the bill Returns: - A tuple containing the new or update pull request and new bill instance, if - applicable + The new bill instance """ - with self._create_submit_task() as submit_task: + draft = pull_request.draft + with self._create_submit_task(enabled=not draft) as submit_task: self._bill: Bill = self.model( name=title, description=body, author=author, pull_request=pull_request, - status=self.model.Status.OPEN, + status=self.model.Status.DRAFT if draft else self.model.Status.OPEN, constitutional=bool(is_constitutional(diff_text)), _submit_task=submit_task, ) @@ -137,9 +140,13 @@ def create_from_github( return bill @contextmanager - def _create_submit_task(self) -> Iterator[PeriodicTask]: + def _create_submit_task(self, *, enabled: bool = True) -> Iterator[PeriodicTask]: """Schedule a task to submit this bill for voting + Args: + enabled: Whether the task should be enabled immediately. Set to False + for draft bills whose voting period hasn't started yet. + Returns: The task that was scheduled """ @@ -153,6 +160,7 @@ def _create_submit_task(self) -> Iterator[PeriodicTask]: name="bill_submit:temp", task="democrasite.webiscite.tasks.submit_bill", one_off=True, + enabled=enabled, # If last_run_at is not set, the task will run relative to when the # scheduler started, not when it was created last_run_at=timezone.now(), diff --git a/democrasite/webiscite/migrations/0006_remove_bill_unique_open_pull_request_and_more.py b/democrasite/webiscite/migrations/0006_remove_bill_unique_open_pull_request_and_more.py new file mode 100644 index 00000000..8f0e97e5 --- /dev/null +++ b/democrasite/webiscite/migrations/0006_remove_bill_unique_open_pull_request_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.11 on 2026-02-16 23:17 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_celery_beat', '0019_alter_periodictasks_options'), + ('webiscite', '0005_alter_bill_name_alter_historicalbill_name_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='bill', + name='unique_open_pull_request', + ), + migrations.AddField( + model_name='historicalpullrequest', + name='draft', + field=models.BooleanField(default=False, help_text='Whether the pull request is a draft on GitHub'), + ), + migrations.AddField( + model_name='pullrequest', + name='draft', + field=models.BooleanField(default=False, help_text='Whether the pull request is a draft on GitHub'), + ), + migrations.AlterField( + model_name='bill', + name='status', + 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), + ), + migrations.AlterField( + model_name='historicalbill', + name='status', + 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), + ), + migrations.AddConstraint( + model_name='bill', + 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'), + ), + ] diff --git a/democrasite/webiscite/models.py b/democrasite/webiscite/models.py index f7c8e894..784a7b11 100644 --- a/democrasite/webiscite/models.py +++ b/democrasite/webiscite/models.py @@ -5,6 +5,7 @@ from django.conf import settings from django.db import models from django.urls import reverse +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django_celery_beat.models import PeriodicTask from model_utils.models import TimeStampedModel @@ -32,6 +33,9 @@ class PullRequest(TimeStampedModel): diff_url = models.URLField(help_text=_("URL to the diff of the pull request")) # Store Github username of author even if they are not a user on the site author_name = models.CharField(max_length=100) + draft = models.BooleanField( + default=False, help_text=_("Whether the pull request is a draft on GitHub") + ) #: status = models.CharField( max_length=6, @@ -72,7 +76,9 @@ def close(self) -> "Bill | None": self.save() try: - bill: Bill = self.bill_set.get(status=Bill.Status.OPEN) + bill: Bill = self.bill_set.get( + status__in=[Bill.Status.OPEN, Bill.Status.DRAFT] + ) except Bill.DoesNotExist: self.log("No open bill found") return None @@ -119,6 +125,7 @@ class Status(models.TextChoices): :meta private:""" + DRAFT = "draft", _("Draft") OPEN = "open", _("Open") APPROVED = "approved", _("Approved") REJECTED = "rejected", _("Rejected") @@ -151,10 +158,10 @@ class Meta: models.UniqueConstraint( fields=("pull_request",), # Can't reference Bill.Status because Bill isn't defined yet - condition=models.Q(status="open"), - name="unique_open_pull_request", + condition=models.Q(status__in=["open", "draft"]), + name="unique_active_pull_request", violation_error_message=_( - "A Bill for this pull request is already open" + "A Bill for this pull request is already active" ), ), ] @@ -247,6 +254,19 @@ def close(self) -> None: self._submit_task.save() self.log("Submit task disabled") + def publish(self) -> None: + """Transition a draft bill to open, enabling voting and scheduling submission""" + if self.status != self.Status.DRAFT: + raise ValueError("Only draft bills can be published") + + self.status = self.Status.OPEN + self.save() + + self._submit_task.enabled = True + self._submit_task.last_run_at = timezone.now() + self._submit_task.save() + self.log("Published") + def submit(self) -> None: """Check if the bill has enough votes to pass and update the status""" # Bill was closed before voting period ended diff --git a/democrasite/webiscite/tests/factories.py b/democrasite/webiscite/tests/factories.py index 226e4682..c1de791e 100644 --- a/democrasite/webiscite/tests/factories.py +++ b/democrasite/webiscite/tests/factories.py @@ -16,6 +16,7 @@ class PullRequestFactory(factory.django.DjangoModelFactory[PullRequest]): title = factory.Faker("text", max_nb_chars=50) author_name = factory.Faker("user_name") status = "open" + draft = False additions = factory.Faker("random_int") deletions = factory.Faker("random_int") sha = factory.Faker("pystr", min_chars=40, max_chars=40) @@ -67,6 +68,7 @@ class GithubPullRequestFactory(factory.Factory[dict[str, Any]]): state = "open" additions = factory.SelfAttribute("bill.pull_request.additions") deletions = factory.SelfAttribute("bill.pull_request.deletions") + draft = False diff_url = "" # Keep blank so request.get raises an error class Meta: diff --git a/democrasite/webiscite/tests/test_models.py b/democrasite/webiscite/tests/test_models.py index 49c50830..95d95125 100644 --- a/democrasite/webiscite/tests/test_models.py +++ b/democrasite/webiscite/tests/test_models.py @@ -49,6 +49,17 @@ def test_close(self, bill: Bill): assert pull_request.status == "closed" assert not pull_request.bill_set.filter(status=Bill.Status.OPEN).exists() + def test_close_draft_bill(self): + bill = BillFactory.create(status=Bill.Status.DRAFT) + pull_request = bill.pull_request + + pull_request.close() + + pull_request.refresh_from_db() + bill.refresh_from_db() + assert pull_request.status == "closed" + assert bill.status == Bill.Status.CLOSED + def test_close_no_bill(self, caplog): pull_request = PullRequestFactory.create() assert pull_request.status == "open" @@ -127,6 +138,20 @@ def test_create_from_github(self, user: User): assert bill.pk is not None assert bill._submit_task.enabled is True # noqa: SLF001 + def test_create_from_github_draft(self, user: User): + pr = PullRequestFactory.create(draft=True) + bill = Bill.objects.create_from_github( + pr.title, + FAKE.text(), + user, + FAKE.text(), + pr, + ) + + assert bill.pk is not None + assert bill.status == Bill.Status.DRAFT + assert bill._submit_task.enabled is False # noqa: SLF001 + def test__create_submit_task(self): # just hit the error branch of finally clause with ( @@ -140,15 +165,21 @@ def test__create_submit_task(self): class TestBill: - def test_unique_open_pull_request(self, bill: Bill): + def test_unique_active_pull_request(self, bill: Bill): bill.status = "closed" bill.save() BillFactory.create(pull_request=bill.pull_request) - with pytest.raises(IntegrityError, match='"unique_open_pull_request"'): + with pytest.raises(IntegrityError, match='"unique_active_pull_request"'): BillFactory.create(pull_request=bill.pull_request) + def test_unique_active_pull_request_draft(self): + bill = BillFactory.create(status=Bill.Status.DRAFT) + + with pytest.raises(IntegrityError, match='"unique_active_pull_request"'): + BillFactory.create(pull_request=bill.pull_request, status=Bill.Status.DRAFT) + def test_str(self): bill = BillFactory.create(name="The Test Act", pk=1, pull_request__number="-2") assert str(bill) == "Bill 1: The Test Act (PR #-2)" @@ -172,6 +203,24 @@ def test_close(self, bill: Bill): assert bill._submit_task.enabled is False # noqa: SLF001 +class TestBillPublish: + def test_publish(self): + bill = BillFactory.create(status=Bill.Status.DRAFT) + bill._submit_task.enabled = False # noqa: SLF001 + bill._submit_task.save() # noqa: SLF001 + + bill.publish() + + bill.refresh_from_db() + assert bill.status == Bill.Status.OPEN + assert bill._submit_task.enabled is True # noqa: SLF001 + assert bill._submit_task.last_run_at is not None # noqa: SLF001 + + def test_publish_not_draft(self, bill: Bill): + with pytest.raises(ValueError, match="Only draft bills can be published"): + bill.publish() + + class TestBillVote: def test_bill_vote_yes_toggle(self, bill: Bill, user: User): bill.vote(user, support=True) @@ -203,8 +252,21 @@ def test_bill_not_open(self, user: User): with pytest.raises(ClosedBillVoteError, match="Bill is not open for voting"): bill.vote(user, support=True) + def test_draft_bill_not_votable(self, user: User): + bill = BillFactory.create(status=Bill.Status.DRAFT) + + with pytest.raises(ClosedBillVoteError, match="Bill is not open for voting"): + bill.vote(user, support=True) + class TestBillSubmit: + def test_draft_bill_not_submitted(self): + bill: Bill = BillFactory.create(status=Bill.Status.DRAFT) + + bill.submit() + + assert bill.status == Bill.Status.DRAFT + def test_bill_not_open(self): bill: Bill = BillFactory.create(status=Bill.Status.CLOSED) diff --git a/democrasite/webiscite/tests/test_webhooks.py b/democrasite/webiscite/tests/test_webhooks.py index a04a2b69..2edbde74 100644 --- a/democrasite/webiscite/tests/test_webhooks.py +++ b/democrasite/webiscite/tests/test_webhooks.py @@ -16,6 +16,7 @@ from democrasite.users.models import User from democrasite.webiscite.models import Bill from democrasite.webiscite.models import PullRequest +from democrasite.webiscite.tests.factories import BillFactory from democrasite.webiscite.tests.factories import GithubPullRequestFactory from democrasite.webiscite.webhooks import GithubWebhookView from democrasite.webiscite.webhooks import PullRequestHandler @@ -171,6 +172,54 @@ def test_opened(self, mock_get, user: User, pr_handler: PullRequestHandler): assert bill.author == user assert bill.constitutional is False + @patch("requests.get") + def test_opened_draft(self, mock_get, user: User, pr_handler: PullRequestHandler): + pr = GithubPullRequestFactory.create(draft=True) + pr["user"]["id"] = SocialAccount.objects.create( + user=user, + provider="github", + uid=faker.Faker().random_int(), + ).uid + + pull_request, bill = pr_handler.opened(pr) + + assert pull_request is not None + assert pull_request.draft is True + assert bill is not None + assert bill.status == Bill.Status.DRAFT + assert bill._submit_task.enabled is False # noqa: SLF001 + + def test_ready_for_review(self, pr_handler: PullRequestHandler): + bill = BillFactory.create(status=Bill.Status.DRAFT) + bill._submit_task.enabled = False # noqa: SLF001 + bill._submit_task.save() # noqa: SLF001 + bill.pull_request.draft = True + bill.pull_request.save() + + pull_request, published_bill = pr_handler.ready_for_review( + {"number": bill.pull_request.number} + ) + + assert pull_request is not None + assert pull_request.draft is False + published_bill.refresh_from_db() + assert published_bill.status == Bill.Status.OPEN + assert published_bill._submit_task.enabled is True # noqa: SLF001 + + def test_ready_for_review_no_pr(self, pr_handler: PullRequestHandler): + result = pr_handler.ready_for_review({"number": 99999}) + assert result == (None, None) + + def test_ready_for_review_no_draft_bill( + self, pr_handler: PullRequestHandler, bill: Bill + ): + pull_request, result_bill = pr_handler.ready_for_review( + {"number": bill.pull_request.number} + ) + + assert pull_request is not None + assert result_bill is None + @patch.object(PullRequestHandler, "opened") def test_reopened(self, mock_opened, pr_handler: PullRequestHandler): # Basically just for 100% coverage diff --git a/democrasite/webiscite/webhooks.py b/democrasite/webiscite/webhooks.py index 88a48bbb..43e41248 100644 --- a/democrasite/webiscite/webhooks.py +++ b/democrasite/webiscite/webhooks.py @@ -105,6 +105,34 @@ def opened(self, pr: dict[str, Any]) -> tuple[PullRequest, Bill | None]: return pull_request, bill + def ready_for_review( + self, pr: dict[str, Any] + ) -> tuple[PullRequest | None, Bill | None]: + """Publish the draft bill associated with the pull request + + Args: + pr: The parsed JSON object representing the pull request + """ + try: + pull_request = PullRequest.objects.get(number=pr["number"]) + except PullRequest.DoesNotExist: + logger.warning( + "PR #%s: Nothing changed (no pull request found)", pr["number"] + ) + return (None, None) + + pull_request.draft = False + pull_request.save() + + try: + bill: Bill = pull_request.bill_set.get(status=Bill.Status.DRAFT) + except Bill.DoesNotExist: + pull_request.log("No draft bill found") + return (pull_request, None) + + bill.publish() + return (pull_request, bill) + def closed(self, pr: dict[str, Any]) -> tuple[PullRequest | None, Bill | None]: """Disables the open bill associated with the pull request From d2107f0f919ee4b458c22b8cabb38a73360dcbbf Mon Sep 17 00:00:00 2001 From: Matthew Foster Walsh <15671892+mfosterw@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:33:27 -0700 Subject: [PATCH 2/2] Add draft bill template test coverage Co-Authored-By: Claude Opus 4.6 --- democrasite/webiscite/tests/test_templates.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/democrasite/webiscite/tests/test_templates.py b/democrasite/webiscite/tests/test_templates.py index e0d9279b..3b839103 100644 --- a/democrasite/webiscite/tests/test_templates.py +++ b/democrasite/webiscite/tests/test_templates.py @@ -38,7 +38,11 @@ def test_logged_in(self, bill: Bill, client: Client): @pytest.mark.parametrize( ("status", "constitutional"), - [(Bill.Status.FAILED, True), (Bill.Status.APPROVED, False)], + [ + (Bill.Status.FAILED, True), + (Bill.Status.APPROVED, False), + (Bill.Status.DRAFT, False), + ], ) def test_bill_closed( self, @@ -107,6 +111,7 @@ def test_logged_out(self, client: Client, constitutional: bool): # noqa: FBT001 ("index", Bill.Status.OPEN), ("my-bills", Bill.Status.APPROVED), ("my-bills", Bill.Status.REJECTED), + ("my-bills", Bill.Status.DRAFT), ("my-bill-votes", Bill.Status.OPEN), ("my-bill-votes", Bill.Status.FAILED), ],