Skip to content
2 changes: 2 additions & 0 deletions every_election/apps/api/tests/test_api_election_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
Original file line number Diff line number Diff line change
@@ -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,
),
),
]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of the consequences of storing these dates in the DB is: Every time we add a new date or fix a bug, we have to write one of these backfill migrations.

Original file line number Diff line number Diff line change
@@ -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
)
]
46 changes: 46 additions & 0 deletions every_election/apps/elections/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand Down
19 changes: 19 additions & 0 deletions every_election/apps/elections/tests/test_election_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
23 changes: 23 additions & 0 deletions packages/uk-election-timetables/tests/elections/test_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
14 changes: 14 additions & 0 deletions packages/uk-election-timetables/tests/elections/test_mayoral.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading