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..346356ede 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,8 @@ 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", + "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/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/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/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 + ) + ] diff --git a/every_election/apps/elections/models.py b/every_election/apps/elections/models.py index ba5610309..c43b425c8 100644 --- a/every_election/apps/elections/models.py +++ b/every_election/apps/elections/models.py @@ -208,16 +208,26 @@ 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" ) + 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", "sopn_publish_deadline", "registration_deadline", "postal_vote_application_deadline", + "proxy_vote_application_deadline", "vac_application_deadline", + "replacement_pack_start_date", ) organisation = models.ForeignKey( @@ -733,11 +743,47 @@ 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": 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): diff --git a/every_election/apps/elections/tests/test_election_model.py b/every_election/apps/elections/tests/test_election_model.py index 24b544060..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) @@ -764,6 +771,10 @@ 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 + ) + 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", @@ -781,6 +792,10 @@ 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 + ) + 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", @@ -796,6 +811,10 @@ 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 + ) + ballot.replacement_pack_start_date = self.POLL_DATE - timedelta(days=4) # should not raise ballot.clean() 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..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 @@ -78,6 +78,34 @@ 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_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 7b648a8c3..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 @@ -33,3 +33,24 @@ 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) + + +# 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 303a7d775..228614f49 100644 --- a/packages/uk-election-timetables/tests/elections/test_local.py +++ b/packages/uk-election-timetables/tests/elections/test_local.py @@ -76,3 +76,26 @@ 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) + + +# 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 9414f69ba..009483ce7 100644 --- a/packages/uk-election-timetables/tests/elections/test_mayoral.py +++ b/packages/uk-election-timetables/tests/elections/test_mayoral.py @@ -29,3 +29,17 @@ 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) + + +# 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_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 a7e919b4b..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 @@ -24,8 +24,22 @@ 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)) 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) + + +# 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_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..37c66299b 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,17 @@ 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) + + +# 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 8e95ac3e4..bd01fc081 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)), @@ -70,3 +70,37 @@ 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) + + +# 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 becdd0c06..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) == 6 + assert len(election.timetable) == 8 assert election.timetable == [ { @@ -73,18 +73,28 @@ 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), "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) == 6 + assert len(election.timetable) == 8 assert election.timetable == [ { @@ -112,18 +122,28 @@ 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), "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) == 3 + assert len(election.timetable) == 5 assert election.timetable == [ { @@ -136,11 +156,21 @@ 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), "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", + }, ] 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)] diff --git a/packages/uk-election-timetables/uk_election_timetables/election.py b/packages/uk-election-timetables/uk_election_timetables/election.py index 14fa2f32b..4de612f72 100644 --- a/packages/uk-election-timetables/uk_election_timetables/election.py +++ b/packages/uk-election-timetables/uk_election_timetables/election.py @@ -21,7 +21,11 @@ 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" + REPLACEMENT_PACK_START_DATE = ( + "First date electors can apply for a postal vote replacement pack" + ) class Election(metaclass=ABCMeta): @@ -47,6 +51,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: """ @@ -58,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: @@ -132,11 +168,21 @@ 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, "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):