From bca745a016b553aef858840b58c0ecce88de48c8 Mon Sep 17 00:00:00 2001 From: Jesudas Joseph Date: Sun, 17 May 2026 22:14:52 -0700 Subject: [PATCH 1/5] Add event status permissions + replace event status field with property --- src/vote/api.py | 18 +++++-- ...t_allow_registration_event_allow_voting.py | 23 +++++++++ ...009_migrate_event_status_to_permissions.py | 48 +++++++++++++++++++ .../migrations/0010_remove_event_status.py | 17 +++++++ src/vote/models.py | 25 +++++++++- src/vote/tests.py | 28 +++++++---- 6 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 src/vote/migrations/0008_event_allow_registration_event_allow_voting.py create mode 100644 src/vote/migrations/0009_migrate_event_status_to_permissions.py create mode 100644 src/vote/migrations/0010_remove_event_status.py diff --git a/src/vote/api.py b/src/vote/api.py index ae61fed..e7e67f2 100644 --- a/src/vote/api.py +++ b/src/vote/api.py @@ -60,11 +60,18 @@ async def update_event_status( if token != event.host_token: raise AuthorizationError - event.status = body.status - if event.status == event.STATUS_CHOICES.CLOSED: event.closed = datetime.now(tz=UTC) - else: + event.allow_registration = False + event.allow_voting = False + elif event.status == event.STATUS_CHOICES.VOTING: + event.allow_registration = False + event.allow_voting = True + elif event.status == event.STATUS_CHOICES.REGISTERING: + event.allow_registration = True + event.allow_voting = False + + if event.status != event.STATUS_CHOICES.CLOSED: event.closed = None await event.asave() @@ -80,7 +87,8 @@ async def close_event( raise AuthorizationError event.closed = datetime.now(tz=UTC) - event.status = event.STATUS_CHOICES.CLOSED + event.allow_registration = False + event.allow_voting = False await event.asave() @@ -94,7 +102,7 @@ async def open_event( raise AuthorizationError event.closed = None - event.status = event.STATUS_CHOICES.VOTING + event.allow_voting = True await event.asave() diff --git a/src/vote/migrations/0008_event_allow_registration_event_allow_voting.py b/src/vote/migrations/0008_event_allow_registration_event_allow_voting.py new file mode 100644 index 0000000..741c922 --- /dev/null +++ b/src/vote/migrations/0008_event_allow_registration_event_allow_voting.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-05-18 04:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vote', '0007_alter_event_status'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='allow_registration', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='event', + name='allow_voting', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/vote/migrations/0009_migrate_event_status_to_permissions.py b/src/vote/migrations/0009_migrate_event_status_to_permissions.py new file mode 100644 index 0000000..faea1ec --- /dev/null +++ b/src/vote/migrations/0009_migrate_event_status_to_permissions.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.7 on 2026-05-18 04:34 + +from django.db import migrations + + +def split_event_status(apps, schema_editor): + Event = apps.get_model("vote", "Event") + for event in Event.objects.all(): + if event.status == "RE": + event.allow_registration = True + event.allow_voting = False + elif event.status == "VO": + event.allow_registration = False + event.allow_voting = True + elif event.status == "CL": + event.allow_registration = False + event.allow_voting = False + else: + raise ValueError(f"Unknown status: {event.status}") + event.save() + + +def reverse_split_event_status(apps, schema_editor): + Event = apps.get_model("vote", "Event") + for event in Event.objects.all(): + if event.allow_registration and not event.allow_voting: + event.status = "RE" + elif not event.allow_registration and event.allow_voting: + event.status = "VO" + elif not event.allow_registration and not event.allow_voting: + event.status = "CL" + else: + raise ValueError( + f"Invalid combination of permissions: {event.allow_registration}, {event.allow_voting}" + ) + event.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("vote", "0008_event_allow_registration_event_allow_voting"), + ] + + operations = [ + migrations.RunPython( + split_event_status, reverse_code=reverse_split_event_status + ) + ] diff --git a/src/vote/migrations/0010_remove_event_status.py b/src/vote/migrations/0010_remove_event_status.py new file mode 100644 index 0000000..afdff2c --- /dev/null +++ b/src/vote/migrations/0010_remove_event_status.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.7 on 2026-05-18 04:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vote', '0009_migrate_event_status_to_permissions'), + ] + + operations = [ + migrations.RemoveField( + model_name='event', + name='status', + ), + ] diff --git a/src/vote/models.py b/src/vote/models.py index fe3bfc6..d1e1802 100644 --- a/src/vote/models.py +++ b/src/vote/models.py @@ -1,6 +1,13 @@ import uuid from django.db import models -from django.db.models import UniqueConstraint +from django.db.models import F, Case, UniqueConstraint, Value, When + +status_expr = Case( + When(allow_registration=True, allow_voting=False, then=Value("RE")), + When(allow_registration=False, allow_voting=True, then=Value("VO")), + When(allow_registration=False, allow_voting=False, then=Value("CL")), + default=Value("CL"), +) class Event(models.Model): @@ -17,7 +24,21 @@ class STATUS_CHOICES(models.TextChoices): show_results = models.BooleanField(default=False) closed = models.DateTimeField(null=True) electoral_system = models.CharField(max_length=2) - status = models.CharField(max_length=2, choices=STATUS_CHOICES, default="RE") + allow_registration = models.BooleanField(default=False) + allow_voting = models.BooleanField(default=False) + + @property + def status(self): + if self.allow_registration and not self.allow_voting: + return self.STATUS_CHOICES.REGISTERING + elif not self.allow_registration and self.allow_voting: + return self.STATUS_CHOICES.VOTING + elif not self.allow_registration and not self.allow_voting: + return self.STATUS_CHOICES.CLOSED + else: + raise ValueError( + f"Invalid combination of permissions: {self.allow_registration}, {self.allow_voting}" + ) class Ballot(models.Model): diff --git a/src/vote/tests.py b/src/vote/tests.py index af659f1..9d3f95e 100644 --- a/src/vote/tests.py +++ b/src/vote/tests.py @@ -80,6 +80,7 @@ def setUpTestData(self): name="Big Cookoff", choices=["Tom's Texas Chili", "Jim's Vegan Chili", "Ed's Fusion Chili"], electoral_system="PL", + allow_registration=True, ) self.ballot = Ballot.objects.create(event=self.event, voter_name="Becky") @@ -115,7 +116,8 @@ async def test_ballot_creation_with_bad_token(self): self.assertEqual(response.status_code, 403) async def test_ballot_creation_with_wrong_event_status(self): - self.event.status = "VO" + self.event.allow_registration = False + self.event.allow_voting = True await self.event.asave() response = await self.aclient.post( @@ -127,7 +129,8 @@ async def test_ballot_creation_with_wrong_event_status(self): ) self.assertEqual(response.status_code, 409) - self.event.status = "CL" + self.event.allow_registration = False + self.event.allow_voting = False await self.event.asave() response = await self.aclient.post( @@ -140,7 +143,8 @@ async def test_ballot_creation_with_wrong_event_status(self): self.assertEqual(response.status_code, 409) async def test_ballot_submission(self): - self.event.status = "VO" + self.event.allow_registration = False + self.event.allow_voting = True await self.event.asave() vote = "Ed's Fusion Chili" @@ -154,7 +158,8 @@ async def test_ballot_submission(self): self.assertTrue(response.json()["vote"], vote) async def test_ballot_resubmission(self): - self.event.status = "VO" + self.event.allow_registration = False + self.event.allow_voting = True await self.event.asave() submission = await self.aclient.post( @@ -172,7 +177,8 @@ async def test_ballot_resubmission(self): self.assertEqual(resubmission.status_code, 409) async def test_ballot_submission_with_bad_token(self): - self.event.status = "VO" + self.event.allow_registration = False + self.event.allow_voting = True await self.event.asave() response = await self.aclient.post( @@ -190,7 +196,8 @@ async def test_ballot_with_wrong_event_status(self): ) self.assertEqual(response.status_code, 409) - self.event.status = "CL" + self.event.allow_registration = False + self.event.allow_voting = False await self.event.asave() response = await self.aclient.post( f"/ballot/{self.ballot.id}/submit", @@ -204,7 +211,8 @@ async def test_ballot_list(self): name="Small Cookoff", choices=["Chilli 1", "Chilli 2", "Chilli 3"], electoral_system="PL", - status="CL", + allow_registration=False, + allow_voting=False, show_results=True, ) await event.asave() @@ -274,8 +282,9 @@ async def test_ballot_list_for_voter_event_not_closed(self): name="Small Cookoff", choices=["Chilli 1", "Chilli 2", "Chilli 3"], electoral_system="PL", - status="RE", show_results=True, + allow_registration=True, + allow_voting=False, ) await event.asave() ballot1 = Ballot(event=event, voter_name="Bob") @@ -295,7 +304,8 @@ async def test_ballot_list_for_voter_event_not_closed(self): self.assertEqual(response.status_code, 403) - event.status = "CL" + event.allow_registration = False + event.allow_voting = False event.show_results = False await event.asave() From 623c160d4429cc87c2f9d40b911ad89e94575056 Mon Sep 17 00:00:00 2001 From: Jesudas Joseph Date: Sun, 17 May 2026 22:15:25 -0700 Subject: [PATCH 2/5] Remove unused import --- src/vote/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vote/models.py b/src/vote/models.py index d1e1802..d163d15 100644 --- a/src/vote/models.py +++ b/src/vote/models.py @@ -1,6 +1,6 @@ import uuid from django.db import models -from django.db.models import F, Case, UniqueConstraint, Value, When +from django.db.models import Case, UniqueConstraint, Value, When status_expr = Case( When(allow_registration=True, allow_voting=False, then=Value("RE")), From 87d9293c5e70f1587345487c76f721dbb85ba29e Mon Sep 17 00:00:00 2001 From: Jesudas Joseph Date: Sun, 17 May 2026 22:32:56 -0700 Subject: [PATCH 3/5] Make events default with allow_registration = False --- .../0011_alter_event_allow_registration.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/vote/migrations/0011_alter_event_allow_registration.py diff --git a/src/vote/migrations/0011_alter_event_allow_registration.py b/src/vote/migrations/0011_alter_event_allow_registration.py new file mode 100644 index 0000000..da98bb5 --- /dev/null +++ b/src/vote/migrations/0011_alter_event_allow_registration.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-05-18 05:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vote', '0010_remove_event_status'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='allow_registration', + field=models.BooleanField(default=False), + ), + ] From 2f3747bee71315fcbb39558a400351e582e6676f Mon Sep 17 00:00:00 2001 From: Jesudas Joseph Date: Sun, 17 May 2026 22:33:10 -0700 Subject: [PATCH 4/5] Remove event status completely. --- src/vote/api.py | 25 ++++++++----------------- src/vote/models.py | 27 +-------------------------- src/vote/schemas.py | 6 ++++-- src/vote/tests.py | 10 ++++++++-- 4 files changed, 21 insertions(+), 47 deletions(-) diff --git a/src/vote/api.py b/src/vote/api.py index e7e67f2..3d29b17 100644 --- a/src/vote/api.py +++ b/src/vote/api.py @@ -60,19 +60,11 @@ async def update_event_status( if token != event.host_token: raise AuthorizationError - if event.status == event.STATUS_CHOICES.CLOSED: - event.closed = datetime.now(tz=UTC) - event.allow_registration = False - event.allow_voting = False - elif event.status == event.STATUS_CHOICES.VOTING: - event.allow_registration = False - event.allow_voting = True - elif event.status == event.STATUS_CHOICES.REGISTERING: - event.allow_registration = True - event.allow_voting = False - - if event.status != event.STATUS_CHOICES.CLOSED: - event.closed = None + if body.allow_registration is not None: + event.allow_registration = body.allow_registration + + if body.allow_voting is not None: + event.allow_voting = body.allow_voting await event.asave() @@ -102,7 +94,6 @@ async def open_event( raise AuthorizationError event.closed = None - event.allow_voting = True await event.asave() @@ -145,7 +136,7 @@ async def list_ballots( raise AuthorizationError if token != event.host_token and ( - event.status != "CL" or (event.status == "CL" and event.show_results is False) + event.closed is None or (event.closed and event.show_results is False) ): raise AuthorizationError @@ -164,7 +155,7 @@ async def create_ballot( if share_token != event.share_token: raise AuthorizationError - if event.status != "RE": + if not event.allow_registration: raise HttpError(409, "Cannot create ballot at this time") ballot = Ballot(event=event, voter_name=voter_name) @@ -191,7 +182,7 @@ async def submit_ballot( Ballot.objects.prefetch_related("event"), pk=ballot_id ) - if ballot.event.status != "VO": + if ballot.event.allow_voting is False: raise HttpError(409, "Event is not accepting ballots.") if token != ballot.token: diff --git a/src/vote/models.py b/src/vote/models.py index d163d15..c541ab7 100644 --- a/src/vote/models.py +++ b/src/vote/models.py @@ -1,21 +1,9 @@ import uuid from django.db import models -from django.db.models import Case, UniqueConstraint, Value, When - -status_expr = Case( - When(allow_registration=True, allow_voting=False, then=Value("RE")), - When(allow_registration=False, allow_voting=True, then=Value("VO")), - When(allow_registration=False, allow_voting=False, then=Value("CL")), - default=Value("CL"), -) +from django.db.models import UniqueConstraint class Event(models.Model): - class STATUS_CHOICES(models.TextChoices): - REGISTERING = "RE", "Registering" - VOTING = "VO", "Voting" - CLOSED = "CL", "Closed" - share_token = models.UUIDField(default=uuid.uuid4, editable=False) host_token = models.UUIDField(default=uuid.uuid4, editable=False) name = models.CharField() @@ -27,19 +15,6 @@ class STATUS_CHOICES(models.TextChoices): allow_registration = models.BooleanField(default=False) allow_voting = models.BooleanField(default=False) - @property - def status(self): - if self.allow_registration and not self.allow_voting: - return self.STATUS_CHOICES.REGISTERING - elif not self.allow_registration and self.allow_voting: - return self.STATUS_CHOICES.VOTING - elif not self.allow_registration and not self.allow_voting: - return self.STATUS_CHOICES.CLOSED - else: - raise ValueError( - f"Invalid combination of permissions: {self.allow_registration}, {self.allow_voting}" - ) - class Ballot(models.Model): token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) diff --git a/src/vote/schemas.py b/src/vote/schemas.py index f5b201c..f6a6b02 100644 --- a/src/vote/schemas.py +++ b/src/vote/schemas.py @@ -9,7 +9,8 @@ class EventStatusUpdateBody(Schema): - status: EventStatus + allow_registration: bool | None = None + allow_voting: bool | None = None class EventCreation(Schema): @@ -21,7 +22,8 @@ class EventCreation(Schema): class EventDetails(EventCreation): id: int closed: datetime | None - status: EventStatus + allow_registration: bool + allow_voting: bool share_token: uuid.UUID show_results: bool diff --git a/src/vote/tests.py b/src/vote/tests.py index 9d3f95e..84057a6 100644 --- a/src/vote/tests.py +++ b/src/vote/tests.py @@ -44,6 +44,9 @@ async def test_read_event_unauthorized(self): self.assertEqual(response.status_code, 403) async def test_close_event(self): + self.event.allow_registration = True + await self.event.asave() + response = await self.aclient.post( f"/event/{self.event.id}/close", headers={"X-API-Key": self.event.host_token}, @@ -53,7 +56,8 @@ async def test_close_event(self): event = await Event.objects.aget(pk=self.event.id) self.assertIsNotNone(event.closed) - self.assertEqual(event.status, event.STATUS_CHOICES.CLOSED) + self.assertFalse(event.allow_registration) + self.assertFalse(event.allow_voting) async def test_open_event(self): self.event.closed = datetime.now(timezone.utc) @@ -68,7 +72,8 @@ async def test_open_event(self): event = await Event.objects.aget(pk=self.event.id) self.assertIsNone(event.closed) - self.assertEqual(event.status, event.STATUS_CHOICES.VOTING) + self.assertFalse(event.allow_registration) + self.assertFalse(event.allow_voting) class BallotTestCase(TestCase): @@ -213,6 +218,7 @@ async def test_ballot_list(self): electoral_system="PL", allow_registration=False, allow_voting=False, + closed=datetime.now(timezone.utc), show_results=True, ) await event.asave() From ce0e15816e227d68d1e45984cc171d1dd78817a5 Mon Sep 17 00:00:00 2001 From: Jesudas Joseph Date: Sun, 17 May 2026 23:09:33 -0700 Subject: [PATCH 5/5] Update some APIs to return more useful responses. --- src/vote/api.py | 18 +++++++++++++++--- src/vote/schemas.py | 6 ++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/vote/api.py b/src/vote/api.py index 3d29b17..3e1976f 100644 --- a/src/vote/api.py +++ b/src/vote/api.py @@ -12,6 +12,7 @@ EventDetails, EventCreation, EventStatusUpdateBody, + EventCloseResponse, ) from .models import Event, Ballot from django.shortcuts import aget_object_or_404 @@ -26,6 +27,8 @@ async def create_event(request, payload: EventCreation): name=payload.name, choices=payload.choices, electoral_system=payload.electoral_system, + allow_registration=payload.allow_registration, + allow_voting=payload.allow_voting, ) await event.asave() @@ -48,8 +51,12 @@ async def read_event( return event -@router.patch("/event/{event_id}/update-status", tags=["event"]) -async def update_event_status( +@router.patch( + "/event/{event_id}/update", + response={200: EventCreationResponse}, + tags=["event"], +) +async def update_event( request, event_id: str, body: EventStatusUpdateBody, @@ -67,9 +74,12 @@ async def update_event_status( event.allow_voting = body.allow_voting await event.asave() + return event -@router.post("/event/{event_id}/close", tags=["event"]) +@router.post( + "/event/{event_id}/close", response={200: EventCloseResponse}, tags=["event"] +) async def close_event( request, event_id: str, token: uuid.UUID = Header(alias="X-API-Key") ): @@ -83,6 +93,8 @@ async def close_event( event.allow_voting = False await event.asave() + return EventCloseResponse(closed=event.closed) + @router.post("/event/{event_id}/open", tags=["event"]) async def open_event( diff --git a/src/vote/schemas.py b/src/vote/schemas.py index f6a6b02..8e1081f 100644 --- a/src/vote/schemas.py +++ b/src/vote/schemas.py @@ -17,6 +17,8 @@ class EventCreation(Schema): name: str choices: List[str] electoral_system: str + allow_registration: bool = False + allow_voting: bool = False class EventDetails(EventCreation): @@ -28,6 +30,10 @@ class EventDetails(EventCreation): show_results: bool +class EventCloseResponse(Schema): + closed: datetime + + class EventCreationResponse(EventDetails): host_token: uuid.UUID