diff --git a/src/vote/api.py b/src/vote/api.py index ae61fed..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, @@ -60,17 +67,19 @@ async def update_event_status( if token != event.host_token: raise AuthorizationError - event.status = body.status + if body.allow_registration is not None: + event.allow_registration = body.allow_registration - if event.status == event.STATUS_CHOICES.CLOSED: - event.closed = datetime.now(tz=UTC) - else: - event.closed = None + if body.allow_voting is not None: + 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") ): @@ -80,9 +89,12 @@ 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() + return EventCloseResponse(closed=event.closed) + @router.post("/event/{event_id}/open", tags=["event"]) async def open_event( @@ -94,7 +106,6 @@ async def open_event( raise AuthorizationError event.closed = None - event.status = event.STATUS_CHOICES.VOTING await event.asave() @@ -137,7 +148,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 @@ -156,7 +167,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) @@ -183,7 +194,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/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/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), + ), + ] diff --git a/src/vote/models.py b/src/vote/models.py index fe3bfc6..c541ab7 100644 --- a/src/vote/models.py +++ b/src/vote/models.py @@ -4,11 +4,6 @@ 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() @@ -17,7 +12,8 @@ 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) class Ballot(models.Model): diff --git a/src/vote/schemas.py b/src/vote/schemas.py index f5b201c..8e1081f 100644 --- a/src/vote/schemas.py +++ b/src/vote/schemas.py @@ -9,23 +9,31 @@ class EventStatusUpdateBody(Schema): - status: EventStatus + allow_registration: bool | None = None + allow_voting: bool | None = None class EventCreation(Schema): name: str choices: List[str] electoral_system: str + allow_registration: bool = False + allow_voting: bool = False class EventDetails(EventCreation): id: int closed: datetime | None - status: EventStatus + allow_registration: bool + allow_voting: bool share_token: uuid.UUID show_results: bool +class EventCloseResponse(Schema): + closed: datetime + + class EventCreationResponse(EventDetails): host_token: uuid.UUID diff --git a/src/vote/tests.py b/src/vote/tests.py index af659f1..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): @@ -80,6 +85,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 +121,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 +134,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 +148,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 +163,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 +182,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 +201,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 +216,9 @@ 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, + closed=datetime.now(timezone.utc), show_results=True, ) await event.asave() @@ -274,8 +288,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 +310,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()