From 6ebc0c6a617f86fd2dcd4c1d08f4f9db5c06ff53 Mon Sep 17 00:00:00 2001 From: chris48s Date: Thu, 13 Aug 2026 08:34:41 +0100 Subject: [PATCH 01/10] fix a few mistakes in comments --- .../tests/elections/test_police_and_crime_commissioner.py | 2 +- .../tests/elections/test_uk_parliament.py | 2 +- .../uk-election-timetables/uk_election_timetables/calendars.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uk-election-timetables/tests/elections/test_police_and_crime_commissioner.py b/packages/uk-election-timetables/tests/elections/test_police_and_crime_commissioner.py index a7e919b4b..ae622cdb2 100644 --- a/packages/uk-election-timetables/tests/elections/test_police_and_crime_commissioner.py +++ b/packages/uk-election-timetables/tests/elections/test_police_and_crime_commissioner.py @@ -24,7 +24,7 @@ def test_postal_vote_application_deadline_police_and_crime_commissioner(): assert election.postal_vote_application_deadline == dt.date(2021, 4, 20) -# Reference election: pcc.2026-05-07 +# Reference election: pcc.2024-05-02 def test_notice_of_election_deadline(): election = PoliceAndCrimeCommissionerElection(dt.date(2024, 5, 2)) diff --git a/packages/uk-election-timetables/tests/elections/test_uk_parliament.py b/packages/uk-election-timetables/tests/elections/test_uk_parliament.py index 8e95ac3e4..4a2b001aa 100644 --- a/packages/uk-election-timetables/tests/elections/test_uk_parliament.py +++ b/packages/uk-election-timetables/tests/elections/test_uk_parliament.py @@ -61,7 +61,7 @@ def test_notice_of_election_deadline(): [ (Country.ENGLAND, dt.date(2023, 4, 3)), # No Easter Monday BH in Scotland - # and Easter Monday is non special-cased for Scotland in + # and Easter Monday is not special-cased for Scotland in # The Voter Identification Regulations 2022 (Country.SCOTLAND, dt.date(2023, 4, 4)), (Country.WALES, dt.date(2023, 4, 3)), diff --git a/packages/uk-election-timetables/uk_election_timetables/calendars.py b/packages/uk-election-timetables/uk_election_timetables/calendars.py index 3a654730c..56b70e9a9 100644 --- a/packages/uk-election-timetables/uk_election_timetables/calendars.py +++ b/packages/uk-election-timetables/uk_election_timetables/calendars.py @@ -168,7 +168,7 @@ def generate( self, year: int, bank_holidays: List[DateMatcher] ) -> List[DateMatcher]: """ - Christmas Eve is a bank holiday but most legislation + Christmas Eve is not a bank holiday but most legislation explicity considers it a "non-working" day anyway. """ return [DateMatcher(name="Christmas Eve", year=year, month=12, day=24)] From a6dc3ca797755cdf45b2e3c7801555d993ac4796 Mon Sep 17 00:00:00 2001 From: chris48s Date: Thu, 13 Aug 2026 08:35:44 +0100 Subject: [PATCH 02/10] calculate proxy vote application deadline --- .../uk_election_timetables/election.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/uk-election-timetables/uk_election_timetables/election.py b/packages/uk-election-timetables/uk_election_timetables/election.py index 14fa2f32b..16e10c532 100644 --- a/packages/uk-election-timetables/uk_election_timetables/election.py +++ b/packages/uk-election-timetables/uk_election_timetables/election.py @@ -21,6 +21,7 @@ class TimetableEvent(Enum): CLOSE_OF_NOMINATIONS = "Close of Nominations" SOPN_PUBLISH_DEADLINE = "SOPN publishing deadline" POSTAL_VOTE_APPLICATION_DEADLINE = "Postal vote application deadline" + PROXY_VOTE_APPLICATION_DEADLINE = "Proxy vote application deadline" VAC_APPLICATION_DEADLINE = "VAC application deadline" @@ -47,6 +48,22 @@ def postal_vote_application_deadline(self) -> dt.date: return working_days_before(self.poll_date, 11, self._calendar()) + @property + def proxy_vote_application_deadline(self) -> dt.date: + """ + Calculates the proxy vote application deadline for this Election + + This is set out in `The Representation of the People (England and Wales) Regulations 2001 `_. + + In Northern Ireland, this is set out in `The Representation of the People (Northern Ireland) Regulations 2008 ` + + :return: datetime.date representing the proxy vote application deadline + """ + if self.country == Country.NORTHERN_IRELAND: + return working_days_before(self.poll_date, 14, self._calendar()) + + return working_days_before(self.poll_date, 6, self._calendar()) + @property def vac_application_deadline(self) -> dt.date: """ @@ -132,6 +149,11 @@ def timetable(self) -> List[Dict]: "date": self.postal_vote_application_deadline, "event": TimetableEvent.POSTAL_VOTE_APPLICATION_DEADLINE.name, }, + { + "label": TimetableEvent.PROXY_VOTE_APPLICATION_DEADLINE.value, + "date": self.proxy_vote_application_deadline, + "event": TimetableEvent.PROXY_VOTE_APPLICATION_DEADLINE.name, + }, { "label": TimetableEvent.VAC_APPLICATION_DEADLINE.value, "date": self.vac_application_deadline, From 7d2b7883a18600fb68847f9e771da8b96b641d27 Mon Sep 17 00:00:00 2001 From: chris48s Date: Thu, 13 Aug 2026 08:41:39 +0100 Subject: [PATCH 03/10] update package tests also add a few missing test cases --- .../elections/test_city_of_london_local.py | 14 +++++++ .../elections/test_greater_london_assembly.py | 14 +++++++ .../tests/elections/test_local.py | 16 +++++++ .../tests/elections/test_mayoral.py | 7 ++++ .../test_northern_ireland_assembly.py | 42 +++++++++++++++++++ .../test_police_and_crime_commissioner.py | 7 ++++ .../elections/test_scottish_parliament.py | 14 +++++++ .../tests/elections/test_senedd_cymru.py | 7 ++++ .../tests/elections/test_uk_parliament.py | 25 +++++++++++ .../tests/test_election.py | 21 ++++++++-- 10 files changed, 164 insertions(+), 3 deletions(-) diff --git a/packages/uk-election-timetables/tests/elections/test_city_of_london_local.py b/packages/uk-election-timetables/tests/elections/test_city_of_london_local.py index f28735d64..f58a4e979 100644 --- a/packages/uk-election-timetables/tests/elections/test_city_of_london_local.py +++ b/packages/uk-election-timetables/tests/elections/test_city_of_london_local.py @@ -78,6 +78,20 @@ def test_notice_of_election_deadline(): assert election.notice_of_election_deadline == dt.date(2021, 3, 25) +def test_proxy_vote_application_deadline(): + """ + note: this test is just reverse-engineered from the code + + There is no local.city-of-london.2021-05-06 + + Replace it with a test based on a real-world example + when we have one to hand. + """ + election = CityOfLondonLocalElection(dt.date(2021, 5, 6)) + + assert election.proxy_vote_application_deadline == dt.date(2021, 4, 27) + + def test_easter_break(): rule = EasterBreakRule() # Test by example: 2022 diff --git a/packages/uk-election-timetables/tests/elections/test_greater_london_assembly.py b/packages/uk-election-timetables/tests/elections/test_greater_london_assembly.py index 7b648a8c3..eaa200224 100644 --- a/packages/uk-election-timetables/tests/elections/test_greater_london_assembly.py +++ b/packages/uk-election-timetables/tests/elections/test_greater_london_assembly.py @@ -33,3 +33,17 @@ def test_notice_of_election_deadline(): election = GreaterLondonAssemblyElection(dt.date(2024, 5, 2)) assert election.notice_of_election_deadline == dt.date(2024, 3, 19) + + +# Reference election: gla.2024-05-02 +def test_postal_vote_application_deadline(): + election = GreaterLondonAssemblyElection(dt.date(2024, 5, 2)) + + assert election.postal_vote_application_deadline == dt.date(2024, 4, 17) + + +# Reference election: gla.2024-05-02 +def test_proxy_vote_application_deadline(): + election = GreaterLondonAssemblyElection(dt.date(2024, 5, 2)) + + assert election.proxy_vote_application_deadline == dt.date(2024, 4, 24) diff --git a/packages/uk-election-timetables/tests/elections/test_local.py b/packages/uk-election-timetables/tests/elections/test_local.py index 303a7d775..dc6401bbb 100644 --- a/packages/uk-election-timetables/tests/elections/test_local.py +++ b/packages/uk-election-timetables/tests/elections/test_local.py @@ -76,3 +76,19 @@ def test_notice_of_election_deadline_northern_ireland(): ) assert election.notice_of_election_deadline == dt.date(2026, 3, 30) + + +# Reference election: local.2026-05-07 +def test_proxy_vote_application_deadline_local_election_england(): + election = LocalElection(dt.date(2026, 5, 7), country=Country.ENGLAND) + + assert election.proxy_vote_application_deadline == dt.date(2026, 4, 28) + + +# Reference election: local.2023-05-18 +def test_proxy_vote_application_deadline_local_election_northern_ireland(): + election = LocalElection( + dt.date(2023, 5, 18), country=Country.NORTHERN_IRELAND + ) + + assert election.proxy_vote_application_deadline == dt.date(2023, 4, 26) diff --git a/packages/uk-election-timetables/tests/elections/test_mayoral.py b/packages/uk-election-timetables/tests/elections/test_mayoral.py index 9414f69ba..291866dcb 100644 --- a/packages/uk-election-timetables/tests/elections/test_mayoral.py +++ b/packages/uk-election-timetables/tests/elections/test_mayoral.py @@ -29,3 +29,10 @@ def test_notice_of_election_deadline(): election = MayoralElection(dt.date(2025, 5, 1)) assert election.notice_of_election_deadline == dt.date(2025, 3, 25) + + +# Reference election: mayor.2025-05-01 +def test_proxy_vote_application_deadline(): + election = MayoralElection(dt.date(2025, 5, 1)) + + assert election.proxy_vote_application_deadline == dt.date(2025, 4, 23) diff --git a/packages/uk-election-timetables/tests/elections/test_northern_ireland_assembly.py b/packages/uk-election-timetables/tests/elections/test_northern_ireland_assembly.py index 0e6444055..b3ba19e46 100644 --- a/packages/uk-election-timetables/tests/elections/test_northern_ireland_assembly.py +++ b/packages/uk-election-timetables/tests/elections/test_northern_ireland_assembly.py @@ -17,3 +17,45 @@ def test_notice_of_election_deadline(): election = NorthernIrelandAssemblyElection(dt.date(2022, 5, 5)) assert election.notice_of_election_deadline == dt.date(2022, 3, 28) + + +def test_registration_deadline(): + """ + note: this test is just reverse-engineered from the code + + There were no NIA elections in Northern Ireland in 2026 + + Replace it with a test based on a real-world example + when we have one to hand (TODO: 2027) + """ + election = NorthernIrelandAssemblyElection(dt.date(2026, 5, 7)) + + assert election.registration_deadline == dt.date(2026, 4, 20) + + +def test_postal_vote_application_deadline(): + """ + note: this test is just reverse-engineered from the code + + There were no NIA elections in Northern Ireland in 2026 + + Replace it with a test based on a real-world example + when we have one to hand (TODO: 2027) + """ + election = NorthernIrelandAssemblyElection(dt.date(2026, 5, 7)) + + assert election.postal_vote_application_deadline == dt.date(2026, 4, 16) + + +def test_proxy_vote_application_deadline(): + """ + note: this test is just reverse-engineered from the code + + There were no NIA elections in Northern Ireland in 2026 + + Replace it with a test based on a real-world example + when we have one to hand (TODO: 2027) + """ + election = NorthernIrelandAssemblyElection(dt.date(2026, 5, 7)) + + assert election.proxy_vote_application_deadline == dt.date(2026, 4, 16) diff --git a/packages/uk-election-timetables/tests/elections/test_police_and_crime_commissioner.py b/packages/uk-election-timetables/tests/elections/test_police_and_crime_commissioner.py index ae622cdb2..19a823611 100644 --- a/packages/uk-election-timetables/tests/elections/test_police_and_crime_commissioner.py +++ b/packages/uk-election-timetables/tests/elections/test_police_and_crime_commissioner.py @@ -29,3 +29,10 @@ def test_notice_of_election_deadline(): election = PoliceAndCrimeCommissionerElection(dt.date(2024, 5, 2)) assert election.notice_of_election_deadline == dt.date(2024, 3, 26) + + +# Reference election: pcc.2024-05-02 +def test_proxy_vote_application_deadline(): + election = PoliceAndCrimeCommissionerElection(dt.date(2024, 5, 2)) + + assert election.proxy_vote_application_deadline == dt.date(2024, 4, 24) diff --git a/packages/uk-election-timetables/tests/elections/test_scottish_parliament.py b/packages/uk-election-timetables/tests/elections/test_scottish_parliament.py index 8cb5e3217..ea7b737dd 100644 --- a/packages/uk-election-timetables/tests/elections/test_scottish_parliament.py +++ b/packages/uk-election-timetables/tests/elections/test_scottish_parliament.py @@ -50,3 +50,17 @@ def test_notice_of_election_deadline(): election = ScottishParliamentElection(dt.date(2026, 5, 7)) assert election.notice_of_election_deadline == dt.date(2026, 3, 25) + + +# Reference election: sp.2026-05-07 +def test_postal_vote_application_deadline(): + election = ScottishParliamentElection(dt.date(2026, 5, 7)) + + assert election.postal_vote_application_deadline == dt.date(2026, 4, 21) + + +# Reference election: sp.2026-05-07 +def test_proxy_vote_application_deadline(): + election = ScottishParliamentElection(dt.date(2026, 5, 7)) + + assert election.proxy_vote_application_deadline == dt.date(2026, 4, 28) diff --git a/packages/uk-election-timetables/tests/elections/test_senedd_cymru.py b/packages/uk-election-timetables/tests/elections/test_senedd_cymru.py index d650ca5ce..0f910f7a6 100644 --- a/packages/uk-election-timetables/tests/elections/test_senedd_cymru.py +++ b/packages/uk-election-timetables/tests/elections/test_senedd_cymru.py @@ -29,3 +29,10 @@ def test_notice_of_election_deadline(): election = SeneddCymruElection(dt.date(2026, 5, 7)) assert election.notice_of_election_deadline == dt.date(2026, 3, 30) + + +# Reference election: senedd.2026-05-07 +def test_proxy_vote_application_deadline(): + election = SeneddCymruElection(dt.date(2026, 5, 7)) + + assert election.proxy_vote_application_deadline == dt.date(2026, 4, 28) diff --git a/packages/uk-election-timetables/tests/elections/test_uk_parliament.py b/packages/uk-election-timetables/tests/elections/test_uk_parliament.py index 4a2b001aa..5d8ca2fde 100644 --- a/packages/uk-election-timetables/tests/elections/test_uk_parliament.py +++ b/packages/uk-election-timetables/tests/elections/test_uk_parliament.py @@ -70,3 +70,28 @@ def test_notice_of_election_deadline(): def test_vac_application_deadline_uk_parliament(country, deadline_date): election = UKParliamentElection(dt.date(2023, 4, 13), country=country) assert election.vac_application_deadline == deadline_date + + +# Reference election: parl.2024-07-04 +def test_proxy_vote_application_deadline_gb(): + election = UKParliamentElection( + dt.date(2024, 7, 4), country=Country.ENGLAND + ) + + assert election.proxy_vote_application_deadline == dt.date(2024, 6, 26) + + +def test_proxy_vote_application_deadline_ni(): + """ + note: this test is just reverse-engineered from the code + + There was no general election in 2026 + + TODO: Replace it with a test based on a real-world example + when we have one to hand + """ + election = UKParliamentElection( + dt.date(2025, 5, 7), country=Country.NORTHERN_IRELAND + ) + + assert election.proxy_vote_application_deadline == dt.date(2025, 4, 14) diff --git a/packages/uk-election-timetables/tests/test_election.py b/packages/uk-election-timetables/tests/test_election.py index becdd0c06..5b2f13d81 100644 --- a/packages/uk-election-timetables/tests/test_election.py +++ b/packages/uk-election-timetables/tests/test_election.py @@ -45,7 +45,7 @@ def test_timetable_vac_application_deadline(): def test_timetable_sort_order(): election = from_election_id("local.2021-05-06", country=Country.ENGLAND) - assert len(election.timetable) == 6 + assert len(election.timetable) == 7 assert election.timetable == [ { @@ -73,6 +73,11 @@ def test_timetable_sort_order(): "date": dt.date(2021, 4, 20), "event": "POSTAL_VOTE_APPLICATION_DEADLINE", }, + { + "label": "Proxy vote application deadline", + "date": dt.date(2021, 4, 27), + "event": "PROXY_VOTE_APPLICATION_DEADLINE", + }, { "label": "VAC application deadline", "date": dt.date(2021, 4, 27), @@ -84,7 +89,7 @@ def test_timetable_sort_order(): def test_timetable_sort_order_scottish_parliament_postal_vote(): election = from_election_id("sp.c.2021-05-06") - assert len(election.timetable) == 6 + assert len(election.timetable) == 7 assert election.timetable == [ { @@ -112,6 +117,11 @@ def test_timetable_sort_order_scottish_parliament_postal_vote(): "date": dt.date(2021, 4, 19), "event": "REGISTRATION_DEADLINE", }, + { + "label": "Proxy vote application deadline", + "date": dt.date(2021, 4, 27), + "event": "PROXY_VOTE_APPLICATION_DEADLINE", + }, { "label": "VAC application deadline", "date": dt.date(2021, 4, 27), @@ -123,7 +133,7 @@ def test_timetable_sort_order_scottish_parliament_postal_vote(): def test_timetable_referendum(): election = from_election_id("ref.2021-05-06", country=Country.ENGLAND) - assert len(election.timetable) == 3 + assert len(election.timetable) == 4 assert election.timetable == [ { @@ -136,6 +146,11 @@ def test_timetable_referendum(): "date": dt.date(2021, 4, 20), "event": "POSTAL_VOTE_APPLICATION_DEADLINE", }, + { + "label": "Proxy vote application deadline", + "date": dt.date(2021, 4, 27), + "event": "PROXY_VOTE_APPLICATION_DEADLINE", + }, { "label": "VAC application deadline", "date": dt.date(2021, 4, 27), From 7d251b3fe85a6413c025b62bab401f6975b62eed Mon Sep 17 00:00:00 2001 From: chris48s Date: Thu, 13 Aug 2026 08:44:22 +0100 Subject: [PATCH 04/10] add proxy vote deadline to election model --- ...lection_proxy_vote_application_deadline.py | 21 +++++++++++++++++++ every_election/apps/elections/models.py | 5 +++++ 2 files changed, 26 insertions(+) create mode 100644 every_election/apps/elections/migrations/0091_election_proxy_vote_application_deadline.py diff --git a/every_election/apps/elections/migrations/0091_election_proxy_vote_application_deadline.py b/every_election/apps/elections/migrations/0091_election_proxy_vote_application_deadline.py new file mode 100644 index 000000000..c165d7e8e --- /dev/null +++ b/every_election/apps/elections/migrations/0091_election_proxy_vote_application_deadline.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.16 on 2026-08-13 06:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("elections", "0090_backfill_new_timetable_fields"), + ] + + operations = [ + migrations.AddField( + model_name="election", + name="proxy_vote_application_deadline", + field=models.DateField( + blank=True, + help_text="Proxy vote application deadline", + null=True, + ), + ), + ] diff --git a/every_election/apps/elections/models.py b/every_election/apps/elections/models.py index ba5610309..5495672c0 100644 --- a/every_election/apps/elections/models.py +++ b/every_election/apps/elections/models.py @@ -208,6 +208,9 @@ class Election(TimeStampedModel): postal_vote_application_deadline = models.DateField( blank=True, null=True, help_text="Postal vote application deadline" ) + proxy_vote_application_deadline = models.DateField( + blank=True, null=True, help_text="Proxy vote application deadline" + ) vac_application_deadline = models.DateField( blank=True, null=True, help_text="VAC application deadline" ) @@ -217,6 +220,7 @@ class Election(TimeStampedModel): "sopn_publish_deadline", "registration_deadline", "postal_vote_application_deadline", + "proxy_vote_application_deadline", "vac_application_deadline", ) @@ -733,6 +737,7 @@ def get_expected_timetable_fields(self): timetable_fields.append("registration_deadline") timetable_fields.append("postal_vote_application_deadline") + timetable_fields.append("proxy_vote_application_deadline") # VAC deadline is only relevant if the election requires ID if self.requires_voter_id == "EA-2022": From 5fb72bc11e1d810af6e780ef4372e8d771dfdac6 Mon Sep 17 00:00:00 2001 From: chris48s Date: Thu, 13 Aug 2026 08:44:48 +0100 Subject: [PATCH 05/10] update application tests --- .../apps/api/tests/test_api_election_endpoint.py | 1 + .../apps/elections/tests/test_election_model.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/every_election/apps/api/tests/test_api_election_endpoint.py b/every_election/apps/api/tests/test_api_election_endpoint.py index 10eaec62e..e0b7883f0 100644 --- a/every_election/apps/api/tests/test_api_election_endpoint.py +++ b/every_election/apps/api/tests/test_api_election_endpoint.py @@ -451,6 +451,7 @@ def test_all_expected_fields_returned(self): "sopn_publish_deadline": "2017-02-27", "registration_deadline": "2017-03-07", "postal_vote_application_deadline": "2017-03-08", + "proxy_vote_application_deadline": "2017-03-15", "vac_application_deadline": null }, "election_id": "local.place-name-0.2017-03-23", diff --git a/every_election/apps/elections/tests/test_election_model.py b/every_election/apps/elections/tests/test_election_model.py index 24b544060..c41d400d2 100644 --- a/every_election/apps/elections/tests/test_election_model.py +++ b/every_election/apps/elections/tests/test_election_model.py @@ -764,6 +764,9 @@ def test_timetable_field_after_poll_date_raises(self): ballot.postal_vote_application_deadline = self.POLL_DATE + timedelta( days=1 ) # after poll + ballot.proxy_vote_application_deadline = self.POLL_DATE - timedelta( + days=6 + ) with self.assertRaisesRegex( ValidationError, "postal_vote_application_deadline must be before poll_open_date", @@ -781,6 +784,9 @@ def test_timetable_field_too_far_before_poll_date_raises(self): ballot.postal_vote_application_deadline = self.POLL_DATE - timedelta( days=11 ) + ballot.proxy_vote_application_deadline = self.POLL_DATE - timedelta( + days=6 + ) with self.assertRaisesRegex( ValidationError, "notice_of_election_deadline must be within 50 days of poll_open_date", @@ -796,6 +802,9 @@ def test_valid_ballot_with_timetable_fields_passes(self): ballot.postal_vote_application_deadline = self.POLL_DATE - timedelta( days=11 ) + ballot.proxy_vote_application_deadline = self.POLL_DATE - timedelta( + days=6 + ) # should not raise ballot.clean() From 70f083630ec16028410faf65a14c732a97e5cb11 Mon Sep 17 00:00:00 2001 From: chris48s Date: Thu, 13 Aug 2026 12:43:56 +0100 Subject: [PATCH 06/10] calculate postal vote replacement pack start date --- .../uk_election_timetables/election.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/uk-election-timetables/uk_election_timetables/election.py b/packages/uk-election-timetables/uk_election_timetables/election.py index 16e10c532..4de612f72 100644 --- a/packages/uk-election-timetables/uk_election_timetables/election.py +++ b/packages/uk-election-timetables/uk_election_timetables/election.py @@ -23,6 +23,9 @@ class TimetableEvent(Enum): POSTAL_VOTE_APPLICATION_DEADLINE = "Postal vote application deadline" PROXY_VOTE_APPLICATION_DEADLINE = "Proxy vote application deadline" VAC_APPLICATION_DEADLINE = "VAC application deadline" + REPLACEMENT_PACK_START_DATE = ( + "First date electors can apply for a postal vote replacement pack" + ) class Election(metaclass=ABCMeta): @@ -75,6 +78,22 @@ def vac_application_deadline(self) -> dt.date: """ return working_days_before(self.poll_date, 6, self._calendar()) + @property + def replacement_pack_start_date(self) -> dt.date: + """ + Calculate the replacement pack start date for an election to the Greater London Assembly + + This is defined by Representation of the People (England and Wales) Regulations 2001, Regulation 78 + https://www.legislation.gov.uk/uksi/2001/341/regulation/78 + and + Representation of the People (Scotland) Regulations 2001, Regulation 78 + https://www.legislation.gov.uk/uksi/2001/497/regulation/78 + + :return: a datetime.date representing the first date electors can apply for a postal vote replacement pack + """ + + return working_days_before(self.poll_date, 4, self._calendar()) + @property @abstractmethod def notice_of_election_deadline(self) -> dt.date: @@ -159,6 +178,11 @@ def timetable(self) -> List[Dict]: "date": self.vac_application_deadline, "event": TimetableEvent.VAC_APPLICATION_DEADLINE.name, }, + { + "label": TimetableEvent.REPLACEMENT_PACK_START_DATE.value, + "date": self.replacement_pack_start_date, + "event": TimetableEvent.REPLACEMENT_PACK_START_DATE.name, + }, ] with contextlib.suppress(NotImplementedError): From d74b06a593e8d81444f4855dabea5cfff4048d70 Mon Sep 17 00:00:00 2001 From: chris48s Date: Thu, 13 Aug 2026 12:44:28 +0100 Subject: [PATCH 07/10] update package tests --- .../elections/test_city_of_london_local.py | 14 +++++++++++++ .../elections/test_greater_london_assembly.py | 7 +++++++ .../tests/elections/test_local.py | 7 +++++++ .../tests/elections/test_mayoral.py | 7 +++++++ .../test_police_and_crime_commissioner.py | 7 +++++++ .../tests/elections/test_senedd_cymru.py | 7 +++++++ .../tests/elections/test_uk_parliament.py | 9 ++++++++ .../tests/test_election.py | 21 ++++++++++++++++--- 8 files changed, 76 insertions(+), 3 deletions(-) diff --git a/packages/uk-election-timetables/tests/elections/test_city_of_london_local.py b/packages/uk-election-timetables/tests/elections/test_city_of_london_local.py index f58a4e979..ed3732ac4 100644 --- a/packages/uk-election-timetables/tests/elections/test_city_of_london_local.py +++ b/packages/uk-election-timetables/tests/elections/test_city_of_london_local.py @@ -92,6 +92,20 @@ def test_proxy_vote_application_deadline(): assert election.proxy_vote_application_deadline == dt.date(2021, 4, 27) +def test_replacement_pack_start_date(): + """ + note: this test is just reverse-engineered from the code + + There is no local.city-of-london.2021-05-06 + + Replace it with a test based on a real-world example + when we have one to hand. + """ + election = CityOfLondonLocalElection(dt.date(2021, 5, 6)) + + assert election.replacement_pack_start_date == dt.date(2021, 4, 29) + + def test_easter_break(): rule = EasterBreakRule() # Test by example: 2022 diff --git a/packages/uk-election-timetables/tests/elections/test_greater_london_assembly.py b/packages/uk-election-timetables/tests/elections/test_greater_london_assembly.py index eaa200224..d81db9b8a 100644 --- a/packages/uk-election-timetables/tests/elections/test_greater_london_assembly.py +++ b/packages/uk-election-timetables/tests/elections/test_greater_london_assembly.py @@ -47,3 +47,10 @@ def test_proxy_vote_application_deadline(): election = GreaterLondonAssemblyElection(dt.date(2024, 5, 2)) assert election.proxy_vote_application_deadline == dt.date(2024, 4, 24) + + +# Reference election: gla.2024-05-02 +def test_replacement_pack_start_date(): + election = GreaterLondonAssemblyElection(dt.date(2024, 5, 2)) + + assert election.replacement_pack_start_date == dt.date(2024, 4, 26) diff --git a/packages/uk-election-timetables/tests/elections/test_local.py b/packages/uk-election-timetables/tests/elections/test_local.py index dc6401bbb..228614f49 100644 --- a/packages/uk-election-timetables/tests/elections/test_local.py +++ b/packages/uk-election-timetables/tests/elections/test_local.py @@ -92,3 +92,10 @@ def test_proxy_vote_application_deadline_local_election_northern_ireland(): ) assert election.proxy_vote_application_deadline == dt.date(2023, 4, 26) + + +# Reference election: local.2026-05-07 +def test_replacement_pack_start_date_england(): + election = LocalElection(dt.date(2026, 5, 7), country=Country.ENGLAND) + + assert election.replacement_pack_start_date == dt.date(2026, 4, 30) diff --git a/packages/uk-election-timetables/tests/elections/test_mayoral.py b/packages/uk-election-timetables/tests/elections/test_mayoral.py index 291866dcb..009483ce7 100644 --- a/packages/uk-election-timetables/tests/elections/test_mayoral.py +++ b/packages/uk-election-timetables/tests/elections/test_mayoral.py @@ -36,3 +36,10 @@ def test_proxy_vote_application_deadline(): election = MayoralElection(dt.date(2025, 5, 1)) assert election.proxy_vote_application_deadline == dt.date(2025, 4, 23) + + +# Reference election: mayor.2025-05-01 +def test_replacement_pack_start_date(): + election = MayoralElection(dt.date(2025, 5, 1)) + + assert election.replacement_pack_start_date == dt.date(2025, 4, 25) diff --git a/packages/uk-election-timetables/tests/elections/test_police_and_crime_commissioner.py b/packages/uk-election-timetables/tests/elections/test_police_and_crime_commissioner.py index 19a823611..9a660cc78 100644 --- a/packages/uk-election-timetables/tests/elections/test_police_and_crime_commissioner.py +++ b/packages/uk-election-timetables/tests/elections/test_police_and_crime_commissioner.py @@ -36,3 +36,10 @@ def test_proxy_vote_application_deadline(): election = PoliceAndCrimeCommissionerElection(dt.date(2024, 5, 2)) assert election.proxy_vote_application_deadline == dt.date(2024, 4, 24) + + +# Reference election: pcc.2024-05-02 +def test_replacement_pack_start_date(): + election = PoliceAndCrimeCommissionerElection(dt.date(2024, 5, 2)) + + assert election.replacement_pack_start_date == dt.date(2024, 4, 26) diff --git a/packages/uk-election-timetables/tests/elections/test_senedd_cymru.py b/packages/uk-election-timetables/tests/elections/test_senedd_cymru.py index 0f910f7a6..37c66299b 100644 --- a/packages/uk-election-timetables/tests/elections/test_senedd_cymru.py +++ b/packages/uk-election-timetables/tests/elections/test_senedd_cymru.py @@ -36,3 +36,10 @@ def test_proxy_vote_application_deadline(): election = SeneddCymruElection(dt.date(2026, 5, 7)) assert election.proxy_vote_application_deadline == dt.date(2026, 4, 28) + + +# Reference election: senedd.2026-05-07 +def test_replacement_pack_start_date(): + election = SeneddCymruElection(dt.date(2026, 5, 7)) + + assert election.replacement_pack_start_date == dt.date(2026, 4, 30) diff --git a/packages/uk-election-timetables/tests/elections/test_uk_parliament.py b/packages/uk-election-timetables/tests/elections/test_uk_parliament.py index 5d8ca2fde..bd01fc081 100644 --- a/packages/uk-election-timetables/tests/elections/test_uk_parliament.py +++ b/packages/uk-election-timetables/tests/elections/test_uk_parliament.py @@ -95,3 +95,12 @@ def test_proxy_vote_application_deadline_ni(): ) assert election.proxy_vote_application_deadline == dt.date(2025, 4, 14) + + +# Reference election: parl.2024-07-04 +def test_replacement_pack_start_date_gb(): + election = UKParliamentElection( + dt.date(2024, 7, 4), country=Country.ENGLAND + ) + + assert election.replacement_pack_start_date == dt.date(2024, 6, 28) diff --git a/packages/uk-election-timetables/tests/test_election.py b/packages/uk-election-timetables/tests/test_election.py index 5b2f13d81..5d7973215 100644 --- a/packages/uk-election-timetables/tests/test_election.py +++ b/packages/uk-election-timetables/tests/test_election.py @@ -45,7 +45,7 @@ def test_timetable_vac_application_deadline(): def test_timetable_sort_order(): election = from_election_id("local.2021-05-06", country=Country.ENGLAND) - assert len(election.timetable) == 7 + assert len(election.timetable) == 8 assert election.timetable == [ { @@ -83,13 +83,18 @@ def test_timetable_sort_order(): "date": dt.date(2021, 4, 27), "event": "VAC_APPLICATION_DEADLINE", }, + { + "label": "First date electors can apply for a postal vote replacement pack", + "date": dt.date(2021, 4, 29), + "event": "REPLACEMENT_PACK_START_DATE", + }, ] def test_timetable_sort_order_scottish_parliament_postal_vote(): election = from_election_id("sp.c.2021-05-06") - assert len(election.timetable) == 7 + assert len(election.timetable) == 8 assert election.timetable == [ { @@ -127,13 +132,18 @@ def test_timetable_sort_order_scottish_parliament_postal_vote(): "date": dt.date(2021, 4, 27), "event": "VAC_APPLICATION_DEADLINE", }, + { + "label": "First date electors can apply for a postal vote replacement pack", + "date": dt.date(2021, 4, 29), + "event": "REPLACEMENT_PACK_START_DATE", + }, ] def test_timetable_referendum(): election = from_election_id("ref.2021-05-06", country=Country.ENGLAND) - assert len(election.timetable) == 4 + assert len(election.timetable) == 5 assert election.timetable == [ { @@ -156,6 +166,11 @@ def test_timetable_referendum(): "date": dt.date(2021, 4, 27), "event": "VAC_APPLICATION_DEADLINE", }, + { + "label": "First date electors can apply for a postal vote replacement pack", + "date": dt.date(2021, 4, 29), + "event": "REPLACEMENT_PACK_START_DATE", + }, ] From 5d286f1fe8ab6753db4e55345647082e25a824ee Mon Sep 17 00:00:00 2001 From: chris48s Date: Thu, 13 Aug 2026 12:44:59 +0100 Subject: [PATCH 08/10] add postal vote replacement pack start date to election model --- ...92_election_replacement_pack_start_date.py | 21 ++++++++++ every_election/apps/elections/models.py | 41 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 every_election/apps/elections/migrations/0092_election_replacement_pack_start_date.py diff --git a/every_election/apps/elections/migrations/0092_election_replacement_pack_start_date.py b/every_election/apps/elections/migrations/0092_election_replacement_pack_start_date.py new file mode 100644 index 000000000..5397504c2 --- /dev/null +++ b/every_election/apps/elections/migrations/0092_election_replacement_pack_start_date.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.16 on 2026-08-13 09:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("elections", "0091_election_proxy_vote_application_deadline"), + ] + + operations = [ + migrations.AddField( + model_name="election", + name="replacement_pack_start_date", + field=models.DateField( + blank=True, + help_text="First date electors can apply for a postal vote replacement pack", + null=True, + ), + ), + ] diff --git a/every_election/apps/elections/models.py b/every_election/apps/elections/models.py index 5495672c0..c43b425c8 100644 --- a/every_election/apps/elections/models.py +++ b/every_election/apps/elections/models.py @@ -214,6 +214,11 @@ class Election(TimeStampedModel): vac_application_deadline = models.DateField( blank=True, null=True, help_text="VAC application deadline" ) + replacement_pack_start_date = models.DateField( + blank=True, + null=True, + help_text="First date electors can apply for a postal vote replacement pack", + ) TIMETABLE_FIELDS = ( "notice_of_election_deadline", "close_of_nominations", @@ -222,6 +227,7 @@ class Election(TimeStampedModel): "postal_vote_application_deadline", "proxy_vote_application_deadline", "vac_application_deadline", + "replacement_pack_start_date", ) organisation = models.ForeignKey( @@ -743,6 +749,41 @@ def get_expected_timetable_fields(self): if self.requires_voter_id == "EA-2022": timetable_fields.append("vac_application_deadline") + area = self.division or self.organisation + territory_code = area.territory_code or self.organisation.territory_code + + if territory_code == "NIR": + """ + Northern Ireland has different rules around replacement packs + In general, absent ballots are harder to obtain due to elevated + concerns about electoral fraud, + and this particularly applies to replacement packs. + Whereas in England/Wales/Scotland you can basically say + "My postal vote didn't arrive. Can I have another one please" + this process doesn't really exist in Northern Ireland. + A replacement can be obtained but you have to prove you + accidentally spoiled your original one. + + Treat the concept of a "replacement_pack_start_date" + as not applicable for Northern Ireland. + """ + pass + elif self.election_id.startswith("sp."): + """ + Replacement packs are allwed in Scottish Parliament elections + But unlike Local and Parliamentary elections there + is no explicit start date from which you're allowed to request one + + The Scottish Parliament (Elections etc.) Order 2015 + https://www.legislation.gov.uk/ssi/2015/425/schedule/4 + + This date is also not applicable for Scottish Parliament, + but for different reasons than Northern Ireland. + """ + pass + else: + timetable_fields.append("replacement_pack_start_date") + return timetable_fields def set_timetable_fields(self): From 45a9385b865b8833cc2ded35df1181cb39f984ba Mon Sep 17 00:00:00 2001 From: chris48s Date: Thu, 13 Aug 2026 12:45:30 +0100 Subject: [PATCH 09/10] update application tests --- .../apps/api/tests/test_api_election_endpoint.py | 1 + .../apps/elections/tests/test_election_model.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/every_election/apps/api/tests/test_api_election_endpoint.py b/every_election/apps/api/tests/test_api_election_endpoint.py index e0b7883f0..346356ede 100644 --- a/every_election/apps/api/tests/test_api_election_endpoint.py +++ b/every_election/apps/api/tests/test_api_election_endpoint.py @@ -452,6 +452,7 @@ def test_all_expected_fields_returned(self): "registration_deadline": "2017-03-07", "postal_vote_application_deadline": "2017-03-08", "proxy_vote_application_deadline": "2017-03-15", + "replacement_pack_start_date": "2017-03-17", "vac_application_deadline": null }, "election_id": "local.place-name-0.2017-03-23", diff --git a/every_election/apps/elections/tests/test_election_model.py b/every_election/apps/elections/tests/test_election_model.py index c41d400d2..9d493c944 100644 --- a/every_election/apps/elections/tests/test_election_model.py +++ b/every_election/apps/elections/tests/test_election_model.py @@ -615,12 +615,19 @@ class TestCleanMethod(TestCase): POLL_DATE = date(2024, 5, 2) def _make_ballot(self, **kwargs): + organisation = OrganisationFactory(territory_code="ENG") + division_set = OrganisationDivisionSetFactory(organisation=organisation) + division = OrganisationDivisionFactory( + divisionset=division_set, territory_code="ENG" + ) defaults = { "election_id": f"local.test.test-div.{self.POLL_DATE}", "election_title": "Test ballot", "election_type": ElectionTypeFactory(election_type="local"), "poll_open_date": self.POLL_DATE, "group_type": None, + "organisation": organisation, + "division": division, } defaults.update(kwargs) return Election(**defaults) @@ -767,6 +774,7 @@ def test_timetable_field_after_poll_date_raises(self): ballot.proxy_vote_application_deadline = self.POLL_DATE - timedelta( days=6 ) + ballot.replacement_pack_start_date = self.POLL_DATE - timedelta(days=4) with self.assertRaisesRegex( ValidationError, "postal_vote_application_deadline must be before poll_open_date", @@ -787,6 +795,7 @@ def test_timetable_field_too_far_before_poll_date_raises(self): ballot.proxy_vote_application_deadline = self.POLL_DATE - timedelta( days=6 ) + ballot.replacement_pack_start_date = self.POLL_DATE - timedelta(days=4) with self.assertRaisesRegex( ValidationError, "notice_of_election_deadline must be within 50 days of poll_open_date", @@ -805,6 +814,7 @@ def test_valid_ballot_with_timetable_fields_passes(self): ballot.proxy_vote_application_deadline = self.POLL_DATE - timedelta( days=6 ) + ballot.replacement_pack_start_date = self.POLL_DATE - timedelta(days=4) # should not raise ballot.clean() From cec9fabcf1e990ed3d987066232bed20e67264e2 Mon Sep 17 00:00:00 2001 From: chris48s Date: Thu, 13 Aug 2026 13:10:29 +0100 Subject: [PATCH 10/10] add backfill migration to populate new fields --- .../0093_backfill_new_timetable_fields.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 every_election/apps/elections/migrations/0093_backfill_new_timetable_fields.py diff --git a/every_election/apps/elections/migrations/0093_backfill_new_timetable_fields.py b/every_election/apps/elections/migrations/0093_backfill_new_timetable_fields.py new file mode 100644 index 000000000..ea325af6b --- /dev/null +++ b/every_election/apps/elections/migrations/0093_backfill_new_timetable_fields.py @@ -0,0 +1,84 @@ +# Generated by Django 5.2.13 on 2026-06-16 10:30 + +from django.db import migrations +from uk_election_timetables.calendars import Country +from uk_election_timetables.election_ids import from_election_id + +country_map = { + "WLS": Country.WALES, + "ENG": Country.ENGLAND, + "NIR": Country.NORTHERN_IRELAND, + "SCT": Country.SCOTLAND, + "GBN": None, +} + + +def backfill_new_timetable_fields(apps, schema_editor): + Election = apps.get_model("elections", "Election") + qs = ( + Election.private_objects.using(schema_editor.connection.alias) + # we can't calculate a timetable for an election with no polling day + .filter(poll_open_date__isnull=False) + # only calculate a timetable for ballots + .filter(group_type__isnull=True) + # We don't have logic for computing timetable for + # EU Parliament elections but some do exist in the DB + # from back in the day + .exclude(election_id__startswith="europarl.") + .select_related("division", "organisation") + ) + + elections_to_update = [] + + for election in qs.iterator(): + area = election.division or election.organisation + territory_code = ( + area.territory_code or election.organisation.territory_code + ) + + timetable = from_election_id( + election.election_id, country=country_map[territory_code] + ) + + # populate the new fields we just added in 0090 and 0091 + if territory_code == "NIR" or election.election_id.startswith("sp."): + election.replacement_pack_start_date = None + else: + election.replacement_pack_start_date = ( + timetable.replacement_pack_start_date + ) + + election.proxy_vote_application_deadline = ( + timetable.proxy_vote_application_deadline + ) + + elections_to_update.append(election) + + batch_size = 2000 + for i in range(0, len(elections_to_update), batch_size): + qs.bulk_update( + elections_to_update[i : i + batch_size], + ["proxy_vote_application_deadline", "replacement_pack_start_date"], + ) + + +def clear_new_timetable_fields(apps, schema_editor): + Election = apps.get_model("elections", "Election") + qs = Election.private_objects.using(schema_editor.connection.alias) + + qs.update( + proxy_vote_application_deadline=None, + replacement_pack_start_date=None, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("elections", "0092_election_replacement_pack_start_date"), + ] + + operations = [ + migrations.RunPython( + backfill_new_timetable_fields, clear_new_timetable_fields + ) + ]