Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -446,7 +446,9 @@ def test_all_expected_fields_returned(self):
"current": true,
"poll_open_date": "2017-03-23",
"timetable": {
"notice_of_election_deadline": "2017-02-16",
"close_of_nominations": "2017-02-24",
"sopn_publish_deadline": "2017-02-27",
"registration_deadline": "2017-03-07",
"postal_vote_application_deadline": "2017-03-08",
"vac_application_deadline": null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 5.2.15 on 2026-07-08 09:13

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("elections", "0088_fix_ni_timetable_fields"),
]

operations = [
migrations.AddField(
model_name="election",
name="notice_of_election_deadline",
field=models.DateField(
blank=True,
help_text="Deadline to publish Notice of Election document",
null=True,
),
),
migrations.AddField(
model_name="election",
name="sopn_publish_deadline",
field=models.DateField(
blank=True,
help_text="Deadline to publish SOPN document",
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.

This is another migration that is going to touch the modified date on basically every ballot object in EE
I do at least know this time round that it will break the EE --> YNR sync and what to do about it when it does

Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Generated by Django 5.2.13 on 2026-06-16 10:30

from django.db import migrations
from django.db.models import F
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)

elections_to_update = []
city_of_london_to_update = []

for election in qs.iterator():
# we can't calculate a timetable for en election with
# no polling day
if election.poll_open_date is None:
continue

# only calculate a timetable for ballots
if election.group_type is not None:
continue

# We don't have logic for computing timetable for
# EU Parliament elections but some do exist in the DB
# from back in the day
if election.election_id.startswith("europarl."):
continue

# There is no nominations or SOPN for refenda
# Notice of election deadline is applicable
# but not implemented
if election.election_id.startswith("ref."):
continue

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 0089
election.notice_of_election_deadline = (
timetable.notice_of_election_deadline
)
election.sopn_publish_deadline = timetable.sopn_publish_deadline
elections_to_update.append(election)

# We used to make a slightly different assumption about
# City of London elections. This changed in 5.0.0 so
# update close_of_nominations for City of London only
if election.election_id.startswith("local.city-of-london."):
election.close_of_nominations = timetable.close_of_nominations
city_of_london_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],
["notice_of_election_deadline", "sopn_publish_deadline"],
)
for i in range(0, len(city_of_london_to_update), batch_size):
qs.bulk_update(
city_of_london_to_update[i : i + batch_size],
["close_of_nominations"],
)


def clear_new_timetable_fields(apps, schema_editor):
Election = apps.get_model("elections", "Election")
qs = Election.private_objects.using(schema_editor.connection.alias)

qs.filter(election_id__startswith="local.city-of-london.").update(
close_of_nominations=F("sopn_publish_deadline")
)

qs.update(
notice_of_election_deadline=None,
sopn_publish_deadline=None,
)


class Migration(migrations.Migration):
dependencies = [
("elections", "0089_more_timetable_fields"),
]

operations = [
migrations.RunPython(
backfill_new_timetable_fields, clear_new_timetable_fields
)
]
51 changes: 45 additions & 6 deletions every_election/apps/elections/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,17 @@ class Election(TimeStampedModel):

# timetable
poll_open_date = models.DateField(blank=True, null=True)
notice_of_election_deadline = models.DateField(
blank=True,
null=True,
help_text="Deadline to publish Notice of Election document",
)
close_of_nominations = models.DateField(
blank=True, null=True, help_text="Close of Nominations"
)
sopn_publish_deadline = models.DateField(
blank=True, null=True, help_text="Deadline to publish SOPN document"
)
registration_deadline = models.DateField(
blank=True, null=True, help_text="Register to vote deadline"
)
Expand All @@ -204,7 +212,9 @@ class Election(TimeStampedModel):
blank=True, null=True, help_text="VAC application deadline"
)
TIMETABLE_FIELDS = (
"notice_of_election_deadline",
"close_of_nominations",
"sopn_publish_deadline",
"registration_deadline",
"postal_vote_application_deadline",
"vac_application_deadline",
Expand Down Expand Up @@ -648,9 +658,11 @@ def clean(self):
)

expected_timetable_fields = self.get_expected_timetable_fields()
optional_timetable_fields = self.get_optional_timetable_fields()
for field in self.TIMETABLE_FIELDS:
if (
field in expected_timetable_fields
and field not in optional_timetable_fields
and getattr(self, field) is None
):
raise ValidationError(f"{field} is required")
Expand All @@ -664,6 +676,12 @@ def clean(self):
)

for field in expected_timetable_fields:
if (
field in optional_timetable_fields
and getattr(self, field) is None
):
continue

if getattr(self, field) > self.poll_open_date:
raise ValidationError(f"{field} must be before poll_open_date")

Expand All @@ -672,6 +690,23 @@ def clean(self):
f"{field} must be within 50 days of poll_open_date"
)

def get_optional_timetable_fields(self):
# timetable is only applicable to elections with
# polling day
if self.poll_open_date is None:
return []

# timetable is only applicable to ballots
if self.group_type is not None:
return []

if self.election_id.startswith("ref."):
# notice_of_election_deadline is conceptually applicable to
# referenda but is valid to leave blank
return ["notice_of_election_deadline"]

return []

def get_expected_timetable_fields(self):
timetable_fields = []

Expand All @@ -689,9 +724,12 @@ def get_expected_timetable_fields(self):
if self.election_id.startswith("europarl."):
return []

# There is no nominations or SOPN for refenda
timetable_fields.append("notice_of_election_deadline")

# There is no nominations or SOPN for referenda
if not self.election_id.startswith("ref."):
timetable_fields.append("close_of_nominations")
timetable_fields.append("sopn_publish_deadline")

timetable_fields.append("registration_deadline")
timetable_fields.append("postal_vote_application_deadline")
Expand All @@ -703,8 +741,9 @@ def get_expected_timetable_fields(self):
return timetable_fields

def set_timetable_fields(self):
fields = self.get_expected_timetable_fields()
if not fields:
expected_timetable_fields = self.get_expected_timetable_fields()
optional_timetable_fields = self.get_optional_timetable_fields()
if not expected_timetable_fields:
return

country_map = {
Expand All @@ -721,10 +760,10 @@ def set_timetable_fields(self):
self.election_id, country=country_map[territory_code]
)

for field in fields:
for field in expected_timetable_fields:
if getattr(self, field) is None:
if field == "close_of_nominations":
setattr(self, field, timetable.sopn_publish_date)
if field in optional_timetable_fields:
setattr(self, field, None)
else:
setattr(self, field, getattr(timetable, field))

Expand Down
Loading