• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

DemocracyClub / WhoCanIVoteFor / 78615e59-2602-4373-b4ce-ad20cc5b761e

07 Apr 2025 01:45PM UTC coverage: 58.389% (+0.1%) from 58.293%
78615e59-2602-4373-b4ce-ad20cc5b761e

Pull #2232

circleci

awdem
update PostcodeiCalView to new error handling
Pull Request #2232: handle invalid postcode errors and api 500s differently

31 of 31 new or added lines in 4 files covered. (100.0%)

89 existing lines in 6 files now uncovered.

2812 of 4816 relevant lines covered (58.39%)

0.58 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.25
/wcivf/apps/elections/models.py
1
import datetime
1✔
2
import re
1✔
3

4
import pytz
1✔
5
from django.conf import settings
1✔
6
from django.contrib.humanize.templatetags.humanize import apnumber
1✔
7
from django.db import models
1✔
8
from django.db.models import DateTimeField, JSONField
1✔
9
from django.db.models.functions import Greatest
1✔
10
from django.template.defaultfilters import pluralize
1✔
11
from django.urls import reverse
1✔
12
from django.utils import timezone
1✔
13
from django.utils.functional import cached_property
1✔
14
from django.utils.html import mark_safe
1✔
15
from django.utils.text import slugify
1✔
16
from django.utils.translation import gettext_lazy as _
1✔
17
from django_extensions.db.models import TimeStampedModel
1✔
18
from uk_election_ids.metadata_tools import (
1✔
19
    PostalVotingRequirementsMatcher,
20
)
21

22
from .helpers import get_election_timetable
1✔
23
from .managers import ElectionManager
1✔
24

25
LOCAL_TZ = pytz.timezone("Europe/London")
1✔
26

27

28
class InvalidPostcodeError(Exception):
1✔
29
    pass
1✔
30

31

32
class ElectionCancellationReason(models.TextChoices):
1✔
33
    NO_CANDIDATES = "NO_CANDIDATES", "No candidates"
1✔
34
    EQUAL_CANDIDATES = "EQUAL_CANDIDATES", "Equal candidates to seats"
1✔
35
    UNDER_CONTESTED = "UNDER_CONTESTED", "Fewer candidates than seats"
1✔
36
    CANDIDATE_DEATH = "CANDIDATE_DEATH", "Death of a candidate"
1✔
37

38

39
class Election(models.Model):
1✔
40
    slug = models.CharField(max_length=128, unique=True)
1✔
41
    election_date = models.DateField()
1✔
42
    name = models.CharField(max_length=128)
1✔
43
    current = models.BooleanField()
1✔
44
    description = models.TextField(blank=True)
1✔
45
    ballot_colour = models.CharField(blank=True, max_length=100)
1✔
46
    election_type = models.CharField(blank=True, max_length=100)
1✔
47
    voting_system = models.ForeignKey(
1✔
48
        "VotingSystem", null=True, blank=True, on_delete=models.CASCADE
49
    )
50
    uses_lists = models.BooleanField(default=False)
1✔
51
    voter_age = models.CharField(blank=True, max_length=100)
1✔
52
    voter_citizenship = models.TextField(blank=True)
1✔
53
    for_post_role = models.TextField(blank=True)
1✔
54
    election_weight = models.IntegerField(default=10)
1✔
55
    metadata = JSONField(null=True)
1✔
56
    any_non_by_elections = models.BooleanField(default=False)
1✔
57

58
    objects = ElectionManager()
1✔
59

60
    class Meta:
1✔
61
        ordering = ["election_date", "election_weight"]
1✔
62

63
    def __str__(self):
1✔
UNCOV
64
        return self.name
×
65

66
    @property
1✔
67
    def in_past(self):
1✔
68
        """
69
        Returns a boolean for whether the election date is in the past
70
        """
71
        return self.election_date < datetime.date.today()
1✔
72

73
    @property
1✔
74
    def is_city_of_london_local_election(self):
1✔
75
        """
76
        Returns boolean for if the election is within City of London.
77
        The city often has different rules to other UK elections so it's useful
78
        to know when we need to special case. For further details:
79
        https://www.cityoflondon.gov.uk/about-us/voting-elections/elections/ward-elections
80
        https://democracyclub.org.uk/blog/2017/03/22/eight-weird-things-about-tomorrows-city-london-elections/
81
        """
82
        return "local.city-of-london" in self.slug
1✔
83

84
    @property
1✔
85
    def election_covers_city_of_london(self):
1✔
86
        """
87
        Returns boolean for if the election is in a parl or GLA constituency partially covering City of London.
88
        The city often has different rules to other UK elections so it's useful
89
        to know when we need to special case. For further details:
90
        https://www.cityoflondon.gov.uk/about-us/voting-elections/elections/ward-elections
91
        https://democracyclub.org.uk/blog/2017/03/22/eight-weird-things-about-tomorrows-city-london-elections/
92
        """
93
        return (
1✔
94
            "parl.cities-of-london-and-westminster" in self.slug
95
            or "gla.c.city-and-east" in self.slug
96
        )
97

98
    @property
1✔
99
    def polls_close(self):
1✔
100
        """
101
        Return a time object for the time the polls close.
102
        Polls close earlier in City of London, for more info:
103
        https://www.cityoflondon.gov.uk/about-us/voting-elections/elections/ward-elections
104
        https://democracyclub.org.uk/blog/2017/03/22/eight-weird-things-about-tomorrows-city-london-elections/
105
        """
106
        if self.is_city_of_london_local_election:
1✔
107
            return datetime.time(20, 0)
1✔
108

109
        return datetime.time(22, 0)
1✔
110

111
    @property
1✔
112
    def polls_open(self):
1✔
113
        """
114
        Return a time object for the time polls open.
115
        Polls open later in City of London, for more info:
116
        https://www.cityoflondon.gov.uk/about-us/voting-elections/elections/ward-elections
117
        https://democracyclub.org.uk/blog/2017/03/22/eight-weird-things-about-tomorrows-city-london-elections/
118
        """
119
        if self.is_city_of_london_local_election:
1✔
120
            return datetime.time(8, 0)
1✔
121

122
        return datetime.time(7, 0)
1✔
123

124
    @property
1✔
125
    def is_election_day(self):
1✔
126
        """
127
        Return boolean for whether it is election day
128
        """
129
        return self.election_date == datetime.date.today()
1✔
130

131
    def friendly_day(self):
1✔
UNCOV
132
        delta = self.election_date - datetime.date.today()
×
133

134
        if delta.days == 0:
×
135
            return "today"
×
136

137
        if delta.days < 0:
×
138
            if delta.days == -1:
×
UNCOV
139
                return "yesterday"
×
140

UNCOV
141
            if delta.days > -5:
×
142
                return "{} days ago".format(delta.days)
×
143

UNCOV
144
            return "on {}".format(self.election_date.strftime("%A %-d %B %Y"))
×
145

146
        if delta.days == 1:
×
UNCOV
147
            return "tomorrow"
×
148

UNCOV
149
        if delta.days < 7:
×
UNCOV
150
            return "in {} days".format(delta.days)
×
151

UNCOV
152
        return "on {}".format(self.election_date.strftime("%A %-d %B %Y"))
×
153

154
    @property
1✔
155
    def nice_election_name(self):
1✔
156
        name = self.name
1✔
157
        if not self.any_non_by_elections:
1✔
158
            name = name.replace("elections", "")
1✔
159
            name = name.replace("election", "")
1✔
160
            name = name.replace("UK Parliament", "UK Parliamentary")
1✔
161
            name = "{} {}".format(name, "by-election")
1✔
162

163
        return name
1✔
164

165
    @property
1✔
166
    def name_without_brackets(self):
1✔
167
        """
168
        Removes any characters from the election name after an opening bracket
169
        TODO name this see if we can do this more reliably based on data from
170
        EE
171
        """
172
        regex = r"\(.*?\)"
1✔
173
        brackets_removed = re.sub(regex, "", self.nice_election_name)
1✔
174
        # remove any extra whitespace
175
        return brackets_removed.replace("  ", " ").strip()
1✔
176

177
    def _election_datetime_tz(self):
1✔
178
        election_date = self.election_date
1✔
179
        election_datetime = datetime.datetime.fromordinal(
1✔
180
            election_date.toordinal()
181
        )
182
        election_datetime.replace(tzinfo=LOCAL_TZ)
1✔
183
        return election_datetime
1✔
184

185
    @property
1✔
186
    def start_time(self):
1✔
187
        election_datetime = self._election_datetime_tz()
1✔
188
        return election_datetime.replace(hour=7)
1✔
189

190
    @property
1✔
191
    def end_time(self):
1✔
192
        election_datetime = self._election_datetime_tz()
1✔
193
        return election_datetime.replace(hour=22)
1✔
194

195
    def get_absolute_url(self):
1✔
196
        return reverse(
1✔
197
            "election_view", args=[str(self.slug), slugify(self.name)]
198
        )
199

200
    def election_booklet(self):
1✔
201
        election_to_booklet = {
1✔
202
            "mayor.greater-manchester-ca.2017-05-04": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2017-05-04/mayoral/mayor.greater-manchester-ca.2017-05-04.pdf",
203
            "mayor.liverpool-city-ca.2017-05-04": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2017-05-04/mayoral/mayor.liverpool-city-ca.2017-05-04.pdf",
204
            "mayor.cambridgeshire-and-peterborough.2017-05-04": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2017-05-04/mayoral/mayor.cambridgeshire-and-peterborough.2017-05-04.pdf",
205
            # noqa
206
            "mayor.west-of-england.2017-05-04": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2017-05-04/mayoral/mayor.west-of-england.2017-05-04.pdf",
207
            "mayor.west-midlands.2017-05-04": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2017-05-04/mayoral/mayor.west-midlands.2017-05-04.pdf",
208
            "mayor.tees-valley.2017-05-04": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2017-05-04/mayoral/mayor.tees-valley.2017-05-04.pdf",
209
            "mayor.north-tyneside.2017-05-04": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2017-05-04/mayoral/mayor.north-tyneside.2017-05-04.pdf",
210
            "mayor.doncaster.2017-05-04": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2017-05-04/mayoral/mayor.doncaster.2017-05-04.pdf",
211
            "mayor.hackney.2018-05-03": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2018-05-03/mayoral/mayor.hackney.2018-05-03.pdf",
212
            "mayor.sheffield-city-ca.2018-05-03": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2018-05-03/mayoral/mayor.sheffield-city-ca.2018-05-03.pdf",
213
            "mayor.lewisham.2018-05-03": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2018-05-03/mayoral/mayor.lewisham.2018-05-03.pdf",
214
            "mayor.tower-hamlets.2018-05-03": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2018-05-03/mayoral/mayor.tower-hamlets.2018-05-03.pdf",
215
            "mayor.newham.2018-05-03": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2018-05-03/mayoral/mayor.newham.2018-05-03.pdf",
216
            "mayor.bristol.2021-05-06": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2021-05-06/mayoral/mayor.bristol.2021-05-06.pdf",
217
            "mayor.cambridgeshire-and-peterborough.2021-05-06": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2021-05-06/mayoral/mayor.cambridgeshire-and-peterborough.2021-05-06.pdf",
218
            "mayor.doncaster.2021-05-06": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2021-05-06/mayoral/mayor.doncaster.2021-05-06.pdf",
219
            "mayor.greater-manchester-ca.2021-05-06": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2021-05-06/mayoral/mayor.greater-manchester-ca.2021-05-06.pdf",
220
            "mayor.liverpool-city-ca.2021-05-06": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2021-05-06/mayoral/mayor.liverpool-city-ca.2021-05-06.pdf",
221
            "mayor.london.2021-05-06": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2021-05-06/mayoral/mayor.london.2021-05-06.pdf",
222
            "mayor.north-tyneside.2021-05-06": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2021-05-06/mayoral/mayor.north-tyneside.2021-05-06.pdf",
223
            "mayor.salford.2021-05-06": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2021-05-06/mayoral/mayor.salford.2021-05-06.pdf",
224
            "mayor.tees-valley.2021-05-06": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2021-05-06/mayoral/mayor.tees-valley.2021-05-06.pdf",
225
            "mayor.west-midlands.2021-05-06": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2021-05-06/mayoral/mayor.west-midlands.2021-05-06.pdf",
226
            "mayor.west-of-england.2021-05-06": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2021-05-06/mayoral/mayor.west-of-england.2021-05-06.pdf",
227
            "mayor.west-yorkshire.2021-05-06": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2021-05-06/mayoral/mayor.west-yorkshire.2021-05-06.pdf",
228
            "mayor.croydon.2022-05-05": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2022-05-05/mayoral/mayor.croydon.2022-05-05.pdf",
229
            "mayor.hackney.2022-05-05": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2022-05-05/mayoral/mayor.hackney.2022-05-05.pdf",
230
            "mayor.lewisham.2022-05-05": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2022-05-05/mayoral/mayor.lewisham.2022-05-05.pdf",
231
            "mayor.newham.2022-05-05": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2022-05-05/mayoral/mayor.newham.2022-05-05.pdf",
232
            "mayor.sheffield-city-ca.2022-05-05": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2022-05-05/mayoral/mayor.sheffield-city-ca.2022-05-05.pdf",
233
            "mayor.tower-hamlets.2022-05-05": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2022-05-05/mayoral/mayor.tower-hamlets.2022-05-05.pdf",
234
            "mayor.hackney.by.2023-11-09": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2023-11-09/mayoral/mayor.hackney.2023-11-09.pdf",
235
            "mayor.lewisham.2024-03-07": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2024-03-07/mayoral/lewisham.mayor.2024-03-07.pdf",
236
            "mayor.london.2024-05-02": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2024-05-02/mayoral/mayor.london.2024-05-02.pdf",
237
            "mayor.tees-valley.2024-05-02": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2024-05-02/mayoral/mayor.tees-valley.2024-05-02.pdf",
238
            "mayor.west-yorkshire.2024-05-02": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2024-05-02/mayoral/mayor.west-yorkshire.2024-05-02.pdf",
239
            "mayor.york-and-north-yorkshire-ca.2024-05-02": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2024-05-02/mayoral/mayor.york-and-north-yorkshire-ca.2024-05-02.pdf",
240
            "mayor.liverpool-city-ca.2024-05-02": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2024-05-02/mayoral/mayor.liverpool-city-ca.2024-05-02.pdf",
241
            "mayor.north-east-ca.2024-05-02": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2024-05-02/mayoral/mayor.north-east-ca.2024-05-02.pdf",
242
            "mayor.greater-manchester-ca.2024-05-02": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2024-05-02/mayoral/mayor.greater-manchester-ca.2024-05-02.pdf",
243
            "mayor.sheffield-city-ca.2024-05-02": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2024-05-02/mayoral/mayor.sheffield-city-ca.2024-05-02.pdf",
244
            "mayor.salford.2024-05-02": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2024-05-02/mayoral/mayor.salford.2024-05-02.pdf",
245
            "mayor.west-midlands.2024-05-02": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2024-05-02/mayoral/mayor.west-midlands.2024-05-02.pdf",
246
            "mayor.east-midlands-cca.2024-05-02": "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets/2024-05-02/mayoral/mayor.east-midlands-cca.2024-05-02.pdf",
247
        }
248

249
        return election_to_booklet.get(self.slug)
1✔
250

251
    @property
1✔
252
    def ynr_link(self):
1✔
UNCOV
253
        return "{}/election/{}/constituencies?{}".format(
×
254
            settings.YNR_BASE, self.slug, settings.YNR_UTM_QUERY_STRING
255
        )
256

257
    @cached_property
1✔
258
    def pluralized_division_name(self):
1✔
259
        """
260
        Returns a string for the pluralised divison name for the posts in the
261
        election
262
        """
263
        pluralise = {
1✔
264
            "parish": "parishes",
265
            "constituency": "constituencies",
266
        }
267
        suffix = self.post_set.first().division_suffix
1✔
268

269
        if not suffix:
1✔
270
            return "posts"
1✔
271

272
        return pluralise.get(suffix, f"{suffix}s")
1✔
273

274

275
class Post(models.Model):
1✔
276
    """
277
    A post has an election and candidates
278
    """
279

280
    DIVISION_TYPE_CHOICES = [
1✔
281
        ("CED", "County Electoral Division"),
282
        ("COP", "Isles of Scilly Parish"),
283
        ("DIW", "District Ward"),
284
        ("EUR", "European Parliament Region"),
285
        ("LAC", "London Assembly Constituency"),
286
        ("LBW", "London Borough Ward"),
287
        ("LGE", "NI Electoral Area"),
288
        ("MTW", "Metropolitan District Ward"),
289
        ("NIE", "NI Assembly Constituency"),
290
        ("SPC", "Scottish Parliament Constituency"),
291
        ("SPE", "Scottish Parliament Region"),
292
        ("UTE", "Unitary Authority Electoral Division"),
293
        ("UTW", "Unitary Authority Ward"),
294
        ("WAC", "Welsh Assembly Constituency"),
295
        ("WAE", "Welsh Assembly Region"),
296
        ("WMC", "Westminster Parliamentary Constituency"),
297
    ]
298

299
    ynr_id = models.CharField(max_length=100, primary_key=True)
1✔
300
    label = models.CharField(blank=True, max_length=255)
1✔
301
    role = models.CharField(blank=True, max_length=255)
1✔
302
    group = models.CharField(blank=True, max_length=100)
1✔
303
    organization = models.CharField(blank=True, max_length=100)
1✔
304
    organization_type = models.CharField(blank=True, max_length=100)
1✔
305
    area_name = models.CharField(blank=True, max_length=100)
1✔
306
    area_id = models.CharField(blank=True, max_length=100)
1✔
307
    territory = models.CharField(blank=True, max_length=3)
1✔
308
    elections = models.ManyToManyField(
1✔
309
        Election, through="elections.PostElection"
310
    )
311
    division_type = models.CharField(
1✔
312
        blank=True, max_length=3, choices=DIVISION_TYPE_CHOICES
313
    )
314

315
    def __str__(self) -> str:
1✔
UNCOV
316
        return f"{self.label} ({self.ynr_id})"
×
317

318
    def nice_organization(self):
1✔
319
        return (
1✔
320
            self.organization.replace(" County Council", "")
321
            .replace(" Borough Council", "")
322
            .replace(" District Council", "")
323
            .replace("London Borough of ", "")
324
            .replace(" Council", "")
325
        )
326

327
    def nice_territory(self):
1✔
328
        if self.territory == "WLS":
1✔
UNCOV
329
            return "Wales"
×
330

331
        if self.territory == "ENG":
1✔
332
            return "England"
1✔
333

334
        if self.territory == "SCT":
1✔
UNCOV
335
            return "Scotland"
×
336

337
        if self.territory == "NIR":
1✔
UNCOV
338
            return "Northern Ireland"
×
339

340
        return self.territory
1✔
341

342
    @property
1✔
343
    def division_description(self):
1✔
344
        """
345
        Return a string to describe the division.
346
        """
347
        mapping = {
1✔
348
            choice[0]: choice[1] for choice in self.DIVISION_TYPE_CHOICES
349
        }
350
        return mapping.get(self.division_type, "")
1✔
351

352
    @property
1✔
353
    def division_suffix(self):
1✔
354
        """
355
        Returns last word of the division_description
356
        """
357
        description = self.division_description
1✔
358
        if not description:
1✔
359
            return ""
1✔
360
        return description.split(" ")[-1].lower()
1✔
361

362
    @property
1✔
363
    def full_label(self):
1✔
364
        """
365
        Returns label with division suffix
366
        """
367
        return f"{self.label} {self.division_suffix}".strip()
1✔
368

369

370
class PostElectionQuerySet(models.QuerySet):
1✔
371
    def last_updated_in_ynr(self):
1✔
372
        """
373
        Returns the ballot with the most recent change made in YNR we know about
374
        """
UNCOV
375
        return self.filter(ynr_modified__isnull=False).latest("ynr_modified")
×
376

377
    def modified_gt_with_related(self, date):
1✔
378
        """
379
        Finds related models that have been updated
380
        since a given date and returns a queryset of PostElections
381
        """
382
        return (
1✔
383
            self.annotate(
384
                last_updated=Greatest(
385
                    "modified",
386
                    "husting__modified",
387
                    "localparty__modified",
388
                    output_field=DateTimeField(),
389
                ),
390
            )
391
            .filter(last_updated__gt=date)
392
            .order_by("last_updated")
393
        )
394

395

396
class PostElection(TimeStampedModel):
1✔
397
    ballot_paper_id = models.CharField(blank=True, max_length=800, unique=True)
1✔
398
    post = models.ForeignKey(Post, on_delete=models.CASCADE)
1✔
399
    election = models.ForeignKey(Election, on_delete=models.CASCADE)
1✔
400
    contested = models.BooleanField(default=True)
1✔
401
    winner_count = models.IntegerField(blank=True, default=1)
1✔
402
    locked = models.BooleanField(default=False)
1✔
403
    cancelled = models.BooleanField(default=False)
1✔
404
    replaced_by = models.ForeignKey(
1✔
405
        "PostElection",
406
        null=True,
407
        blank=True,
408
        related_name="replaces",
409
        on_delete=models.CASCADE,
410
    )
411
    metadata = JSONField(null=True)
1✔
412
    voting_system = models.ForeignKey(
1✔
413
        "VotingSystem", null=True, blank=True, on_delete=models.CASCADE
414
    )
415
    wikipedia_url = models.CharField(blank=True, null=True, max_length=800)
1✔
416
    wikipedia_bio = models.TextField(null=True)
1✔
417
    ynr_modified = models.DateTimeField(
1✔
418
        blank=True,
419
        null=True,
420
        help_text="Timestamp of when this ballot was updated in the YNR",
421
    )
422
    requires_voter_id = models.CharField(blank=True, null=True, max_length=50)
1✔
423
    cancellation_reason = models.CharField(
1✔
424
        max_length=16,
425
        null=True,
426
        blank=True,
427
        choices=ElectionCancellationReason.choices,
428
        default=None,
429
    )
430
    ballot_papers_issued = models.IntegerField(blank=True, null=True)
1✔
431
    electorate = models.IntegerField(blank=True, null=True)
1✔
432
    turnout = models.IntegerField(blank=True, null=True)
1✔
433
    spoilt_ballots = models.IntegerField(blank=True, null=True)
1✔
434

435
    objects = PostElectionQuerySet.as_manager()
1✔
436

437
    class Meta:
1✔
438
        get_latest_by = "ynr_modified"
1✔
439

440
    def __str__(self):
1✔
UNCOV
441
        return self.ballot_paper_id
×
442

443
    @property
1✔
444
    def has_results(self):
1✔
445
        """
446
        Returns a boolean for if the election has results
447
        """
448
        return bool(
1✔
449
            self.spoilt_ballots
450
            or self.ballot_papers_issued
451
            or self.turnout
452
            or self.electorate
453
            or self.personpost_set.filter(elected=True)
454
        )
455

456
    @property
1✔
457
    def expected_sopn_date(self):
1✔
458
        try:
1✔
459
            return get_election_timetable(
1✔
460
                self.ballot_paper_id, self.post.territory
461
            ).sopn_publish_date
462
        except AttributeError:
1✔
463
            return None
1✔
464

465
    @property
1✔
466
    def registration_deadline(self):
1✔
467
        try:
1✔
468
            date = get_election_timetable(
1✔
469
                self.ballot_paper_id, self.post.territory
470
            ).registration_deadline
471
        except AttributeError:
1✔
472
            return None
1✔
473

474
        return date.strftime("%d %B %Y")
1✔
475

476
    @property
1✔
477
    def past_registration_deadline(self):
1✔
478
        try:
1✔
479
            registration_deadline = get_election_timetable(
1✔
480
                self.ballot_paper_id, self.post.territory
481
            ).registration_deadline
482
        except AttributeError:
1✔
483
            return None
1✔
484

485
        return registration_deadline < datetime.date.today()
1✔
486

487
    @property
1✔
488
    def postal_vote_application_deadline(self):
1✔
489
        try:
×
490
            return get_election_timetable(
×
491
                self.ballot_paper_id, self.post.territory
492
            ).postal_vote_application_deadline
UNCOV
493
        except AttributeError:
×
494
            return None
×
495

496
    @property
1✔
497
    def past_vac_application_deadline(self):
1✔
498
        try:
×
499
            vac_application_deadline = get_election_timetable(
×
500
                self.ballot_paper_id, self.post.territory
501
            ).vac_application_deadline
UNCOV
502
        except AttributeError:
×
UNCOV
503
            return None
×
504

505
        return vac_application_deadline < datetime.date.today()
×
506

507
    @property
1✔
508
    def vac_application_deadline(self):
1✔
509
        try:
×
510
            return get_election_timetable(
×
511
                self.ballot_paper_id, self.post.territory
512
            ).vac_application_deadline
UNCOV
513
        except AttributeError:
×
UNCOV
514
            return None
×
515

516
    @property
1✔
517
    def postal_vote_requires_form(self):
1✔
518
        matcher = PostalVotingRequirementsMatcher(
1✔
519
            election_id=self.election.slug, nation=self.post.territory
520
        )
521

522
        voting_requirements_legislation = (
1✔
523
            matcher.get_postal_voting_requirements()
524
        )
525

526
        if voting_requirements_legislation == "EA-2022":
1✔
527
            return True
1✔
528
        return False
1✔
529

530
    @property
1✔
531
    def is_mayoral(self):
1✔
532
        """
533
        Return a boolean for if this is a mayoral election, determined by
534
        checking ballot paper id
535
        """
536
        return self.ballot_paper_id.startswith("mayor")
1✔
537

538
    @property
1✔
539
    def is_parliamentary(self):
1✔
540
        """
541
        Return a boolean for if this is a parliamentary election, determined by
542
        checking ballot paper id
543
        """
544
        return self.ballot_paper_id.startswith("parl")
1✔
545

546
    @property
1✔
547
    def is_london_assembly_additional(self):
1✔
548
        """
549
        Return a boolean for if this is a London Assembley additional ballot
550
        """
551
        return self.ballot_paper_id.startswith("gla.a")
1✔
552

553
    @property
1✔
554
    def is_pcc(self):
1✔
555
        """
556
        Return a boolean for if this is a PCC ballot
557
        """
558
        return self.ballot_paper_id.startswith("pcc")
1✔
559

560
    @property
1✔
561
    def is_constituency(self):
1✔
562
        return self.ballot_paper_id.startswith(("gla.c", "senedd.c", "sp.c"))
1✔
563

564
    @property
1✔
565
    def is_regional(self):
1✔
566
        return self.ballot_paper_id.startswith(("gla.r", "senedd.r", "sp.r"))
1✔
567

568
    @property
1✔
569
    def is_referendum(self):
1✔
570
        """
571
        Return a boolean for if this is a Referendum ballot
572
        """
573
        return self.ballot_paper_id.startswith("ref.")
1✔
574

575
    @property
1✔
576
    def friendly_name(self):
1✔
577
        """
578
        Helper property used in templates to build a 'friendly' name using
579
        details from associated Post object, with exceptions for mayoral and
580
        Police and Crime Commissioner elections
581
        """
582
        if self.is_mayoral:
1✔
583
            return f"{self.post.full_label} mayoral election"
1✔
584

585
        if self.is_pcc:
1✔
586
            label = self.post.full_label.replace(" Police", "")
1✔
587
            return f"{label} Police force area"
1✔
588

589
        return self.post.full_label
1✔
590

591
    def get_absolute_url(self):
1✔
592
        if self.ballot_paper_id.startswith("tmp_"):
1✔
UNCOV
593
            return reverse("home_view")
×
594
        return reverse(
1✔
595
            "election_view",
596
            args=[str(self.ballot_paper_id), slugify(self.post.label)],
597
        )
598

599
    @property
1✔
600
    def ynr_link(self):
1✔
601
        return "{}/elections/{}?{}".format(
1✔
602
            settings.YNR_BASE,
603
            self.ballot_paper_id,
604
            settings.YNR_UTM_QUERY_STRING,
605
        )
606

607
    @property
1✔
608
    def ynr_sopn_link(self):
1✔
UNCOV
609
        return "{}/elections/{}/sopn/?{}".format(
×
610
            settings.YNR_BASE,
611
            self.ballot_paper_id,
612
            settings.YNR_UTM_QUERY_STRING,
613
        )
614

615
    @property
1✔
616
    def short_cancelled_message_html(self):
1✔
617
        if not self.cancelled:
1✔
618
            return ""
1✔
619
        message = None
1✔
620

621
        if self.cancellation_reason:
1✔
622
            if self.cancellation_reason == "CANDIDATE_DEATH":
1✔
623
                message = """<strong> ❌ This election has been cancelled due to the death of a candidate.</strong>"""
1✔
624
            else:
625
                message = """<strong> ❌ The poll for this election will not take place because it is uncontested.</strong>"""
1✔
626
        else:
627
            # Leaving this in for now as we transition away from metadata
628
            if self.metadata and self.metadata.get("cancelled_election"):
1✔
629
                title = self.metadata["cancelled_election"].get("title")
×
UNCOV
630
                url = self.metadata["cancelled_election"].get("url")
×
UNCOV
631
                message = title
×
UNCOV
632
                if url:
×
UNCOV
633
                    message = (
×
634
                        """<strong> ❌ <a href="{}">{}</a></strong>""".format(
635
                            url, title
636
                        )
637
                    )
638
        if not message:
1✔
639
            if self.election.in_past:
1✔
640
                message = "(The poll for this election was cancelled)"
1✔
641
            else:
UNCOV
642
                message = "<strong>(The poll for this election has been cancelled)</strong>"
×
643

644
        return mark_safe(message)
1✔
645

646
    @property
1✔
647
    def get_voting_system(self):
1✔
648
        if self.voting_system:
1✔
UNCOV
649
            return self.voting_system
×
650

651
        return self.election.voting_system
1✔
652

653
    @property
1✔
654
    def display_as_party_list(self):
1✔
655
        if (
1✔
656
            self.get_voting_system
657
            and self.get_voting_system.slug in settings.PARTY_LIST_VOTING_TYPES
658
        ):
UNCOV
659
            return True
×
660
        return False
1✔
661

662
    @cached_property
1✔
663
    def next_ballot(self):
1✔
664
        """
665
        Return the next ballot for the related post. Return None if this is
666
        the current election to avoid making an unnecessary db query.
667
        """
668
        if self.election.current:
1✔
669
            return None
1✔
670

671
        try:
1✔
672
            return self.post.postelection_set.filter(
1✔
673
                election__election_date__gt=self.election.election_date,
674
                election__election_date__gte=datetime.date.today(),
675
                election__election_type=self.election.election_type,
676
            ).latest("election__election_date")
677
        except PostElection.DoesNotExist:
1✔
678
            return None
1✔
679

680
    @property
1✔
681
    def party_ballot_count(self):
1✔
682
        if self.personpost_set.exists():
1✔
683
            people = self.personpost_set
1✔
684
            if self.election.uses_lists:
1✔
685
                ind_candidates = people.filter(party_id="ynmp-party:2").count()
1✔
686
                num_other_parties = (
1✔
687
                    people.exclude(party_id="ynmp-party:2")
688
                    .values("party_id")
689
                    .distinct()
690
                    .count()
691
                )
692
                ind_and_parties = ind_candidates + num_other_parties
1✔
693
                ind_and_parties_apnumber = apnumber(ind_and_parties)
1✔
694
                ind_and_parties_pluralized = pluralize(ind_and_parties)
1✔
695
                value = f"{ind_and_parties_apnumber} parties"
1✔
696
                if ind_candidates:
1✔
697
                    value = f"{value} or independent candidate{ind_and_parties_pluralized}"
1✔
698
                return value
1✔
699

700
            num_candidates = people.count()
1✔
701
            candidates_apnumber = apnumber(num_candidates)
1✔
702
            candidates_pluralized = pluralize(num_candidates)
1✔
703
            return f"{candidates_apnumber} candidate{candidates_pluralized}"
1✔
704

UNCOV
705
        return None
×
706

707
    @property
1✔
708
    def should_display_sopn_info(self):
1✔
709
        """
710
        Return boolean for whether to display text about SOPN
711
        """
712
        if self.election.in_past:
1✔
713
            return False
1✔
714

715
        if self.locked:
1✔
716
            return True
1✔
717

718
        return bool(self.expected_sopn_date)
1✔
719

720
    @property
1✔
721
    def past_expected_sopn_day(self):
1✔
722
        """
723
        Return boolean for the date we expected the sopn date
724
        """
725
        return self.expected_sopn_date <= timezone.now().date()
1✔
726

727
    @property
1✔
728
    def should_show_candidates(self):
1✔
729
        if not self.cancelled:
1✔
730
            return True
1✔
731
        if not self.metadata:
1✔
732
            return True
1✔
UNCOV
733
        if self.cancellation_reason in ["CANDIDATE_DEATH"]:
×
UNCOV
734
            return False
×
UNCOV
735
        return True
×
736

737
    @property
1✔
738
    def get_postal_voting_requirements(self):
1✔
739
        try:
1✔
740
            matcher = PostalVotingRequirementsMatcher(
1✔
741
                self.ballot_paper_id, nation=self.post.territory
742
            )
743
            return matcher.get_postal_voting_requirements()
1✔
UNCOV
744
        except Exception:
×
UNCOV
745
            return None
×
746

747

748
class VotingSystem(models.Model):
1✔
749
    slug = models.SlugField(primary_key=True)
1✔
750
    name = models.CharField(blank=True, max_length=100)
1✔
751
    wikipedia_url = models.URLField(blank=True)
1✔
752
    description = models.TextField(blank=True)
1✔
753

754
    def __str__(self):
1✔
755
        return self.name
×
756

757
    @property
1✔
758
    def uses_party_lists(self):
1✔
759
        return self.slug in ["PR-CL", "AMS"]
×
760

761
    @property
1✔
762
    def get_absolute_url(self):
1✔
763
        if self.slug == "FPTP":
×
764
            return reverse("fptp_voting_system_view")
×
765
        if self.slug == "AMS":
×
766
            return reverse("ams_voting_system_view")
×
UNCOV
767
        if self.slug == "sv":
×
768
            return reverse("sv_voting_system_view")
×
UNCOV
769
        if self.slug == "STV":
×
UNCOV
770
            return reverse("stv_voting_system_view")
×
771

772
        return None
×
773

774
    @property
1✔
775
    def get_name(self):
1✔
776
        if self.slug == "FPTP":
×
777
            return _("First-past-the-post")
×
778
        if self.slug == "AMS":
×
779
            return _("Additional Member System")
×
UNCOV
780
        if self.slug == "sv":
×
781
            return _("Supplementary Vote")
×
UNCOV
782
        if self.slug == "STV":
×
UNCOV
783
            return _("Single Transferable Vote")
×
784

UNCOV
785
        return None
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc