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

DemocracyClub / WhoCanIVoteFor / e1a02576-fed3-4822-b3d7-83768ed61fb1

09 Oct 2025 04:45PM UTC coverage: 58.669% (+0.1%) from 58.523%
e1a02576-fed3-4822-b3d7-83768ed61fb1

Pull #2299

circleci

chris48s
register markdown_subset filter, use it for candidate statements
Pull Request #2299: WIP render good markdown, but not bad markdown

11 of 11 new or added lines in 1 file covered. (100.0%)

268 existing lines in 11 files now uncovered.

2927 of 4989 relevant lines covered (58.67%)

0.59 hits per line

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

82.0
/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, Q
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 ElectionCancellationReason(models.TextChoices):
1✔
29
    NO_CANDIDATES = "NO_CANDIDATES", "No candidates"
1✔
30
    EQUAL_CANDIDATES = "EQUAL_CANDIDATES", "Equal candidates to seats"
1✔
31
    UNDER_CONTESTED = "UNDER_CONTESTED", "Fewer candidates than seats"
1✔
32
    CANDIDATE_DEATH = "CANDIDATE_DEATH", "Death of a candidate"
1✔
33

34

35
class ByElectionReason(models.TextChoices):
1✔
36
    """
37
    Reasons why a by-election may be triggered.
38

39
    Not all of these can be applied to all election types.
40

41
    e.g. a recall petition is only used in Westminster, and failure to attend meetings
42
    applies to local government.
43

44
    The choices here are in part based on:
45
        UK Electoral Commission guidance on casual vacancies:
46
        https://www.electoralcommission.org.uk/guidance-returning-officers-administering-local-government-elections-england/casual-vacancies-and-elections/how-casual-vacancies-occur
47
    """
48

49
    DEATH = "DEATH", "The elected member died"
1✔
50
    RESIGNATION = "RESIGNATION", "The elected member resigned"
1✔
51
    ELECTORAL_COURT = (
1✔
52
        "ELECTORAL_COURT",
53
        "The election of the elected member was declared void by an election court",
54
    )
55
    FAILURE_TO_ACCEPT = (
1✔
56
        "FAILURE_TO_ACCEPT",
57
        "The previous election winner did not sign a declaration of acceptance",
58
    )
59
    FAILURE_TO_ATTEND_MEETINGS = (
1✔
60
        "FAILURE_TO_ATTEND_MEETINGS",
61
        "The elected member failed to attend meetings for six months",
62
    )
63
    DISQUALIFICATION = "DISQUALIFICATION", "The elected member was disqualified"
1✔
64
    LOSING_QUALIFICATION = (
1✔
65
        "LOSING_QUALIFICATION",
66
        "The elected member no longer qualified as a registered elector",
67
    )
68
    RECALL_PETITION = (
1✔
69
        "RECALL_PETITION",
70
        "The elected member was recalled by a successful recall petition",
71
    )
72
    OTHER = "OTHER", "Other"
1✔
73
    UNKNOWN = "UNKNOWN", "Unknown"
1✔
74
    NOT_APPLICABLE = "", "Neither a by-election nor a ballot"
1✔
75

76

77
class Election(models.Model):
1✔
78
    slug = models.CharField(max_length=128, unique=True)
1✔
79
    election_date = models.DateField()
1✔
80
    name = models.CharField(max_length=128)
1✔
81
    current = models.BooleanField()
1✔
82
    description = models.TextField(blank=True)
1✔
83
    ballot_colour = models.CharField(blank=True, max_length=100)
1✔
84
    election_type = models.CharField(blank=True, max_length=100)
1✔
85
    voting_system = models.ForeignKey(
1✔
86
        "VotingSystem", null=True, blank=True, on_delete=models.CASCADE
87
    )
88
    uses_lists = models.BooleanField(default=False)
1✔
89
    voter_age = models.CharField(blank=True, max_length=100)
1✔
90
    voter_citizenship = models.TextField(blank=True)
1✔
91
    for_post_role = models.TextField(blank=True)
1✔
92
    election_weight = models.IntegerField(default=10)
1✔
93
    metadata = JSONField(null=True)
1✔
94
    any_non_by_elections = models.BooleanField(default=False)
1✔
95

96
    objects = ElectionManager()
1✔
97

98
    class Meta:
1✔
99
        ordering = ["election_date", "election_weight"]
1✔
100

101
    def __str__(self):
1✔
102
        return self.name
×
103

104
    @property
1✔
105
    def in_past(self):
1✔
106
        """
107
        Returns a boolean for whether the election date is in the past
108
        """
109
        return self.election_date < datetime.date.today()
1✔
110

111
    @property
1✔
112
    def is_city_of_london_local_election(self):
1✔
113
        """
114
        Returns boolean for if the election is within City of London.
115
        The city often has different rules to other UK elections so it's useful
116
        to know when we need to special case. For further details:
117
        https://www.cityoflondon.gov.uk/about-us/voting-elections/elections/ward-elections
118
        https://democracyclub.org.uk/blog/2017/03/22/eight-weird-things-about-tomorrows-city-london-elections/
119
        """
120
        return "local.city-of-london" in self.slug
1✔
121

122
    @property
1✔
123
    def election_covers_city_of_london(self):
1✔
124
        """
125
        Returns boolean for if the election is in a parl or GLA constituency partially covering City of London.
126
        The city often has different rules to other UK elections so it's useful
127
        to know when we need to special case. For further details:
128
        https://www.cityoflondon.gov.uk/about-us/voting-elections/elections/ward-elections
129
        https://democracyclub.org.uk/blog/2017/03/22/eight-weird-things-about-tomorrows-city-london-elections/
130
        """
131
        return (
1✔
132
            "parl.cities-of-london-and-westminster" in self.slug
133
            or "gla.c.city-and-east" in self.slug
134
        )
135

136
    @property
1✔
137
    def polls_close(self):
1✔
138
        """
139
        Return a time object for the time the polls close.
140
        Polls close earlier in City of London, for more info:
141
        https://www.cityoflondon.gov.uk/about-us/voting-elections/elections/ward-elections
142
        https://democracyclub.org.uk/blog/2017/03/22/eight-weird-things-about-tomorrows-city-london-elections/
143
        """
144
        if self.is_city_of_london_local_election:
1✔
145
            return datetime.time(20, 0)
1✔
146

147
        return datetime.time(22, 0)
1✔
148

149
    @property
1✔
150
    def polls_open(self):
1✔
151
        """
152
        Return a time object for the time polls open.
153
        Polls open later in City of London, for more info:
154
        https://www.cityoflondon.gov.uk/about-us/voting-elections/elections/ward-elections
155
        https://democracyclub.org.uk/blog/2017/03/22/eight-weird-things-about-tomorrows-city-london-elections/
156
        """
157
        if self.is_city_of_london_local_election:
1✔
158
            return datetime.time(8, 0)
1✔
159

160
        return datetime.time(7, 0)
1✔
161

162
    @property
1✔
163
    def is_election_day(self):
1✔
164
        """
165
        Return boolean for whether it is election day
166
        """
167
        return self.election_date == datetime.date.today()
1✔
168

169
    def friendly_day(self):
1✔
170
        delta = self.election_date - datetime.date.today()
×
171

172
        if delta.days == 0:
×
173
            return "today"
×
174

175
        if delta.days < 0:
×
176
            if delta.days == -1:
×
177
                return "yesterday"
×
178

179
            if delta.days > -5:
×
180
                return "{} days ago".format(delta.days)
×
181

182
            return "on {}".format(self.election_date.strftime("%A %-d %B %Y"))
×
183

184
        if delta.days == 1:
×
185
            return "tomorrow"
×
186

187
        if delta.days < 7:
×
188
            return "in {} days".format(delta.days)
×
189

190
        return "on {}".format(self.election_date.strftime("%A %-d %B %Y"))
×
191

192
    @property
1✔
193
    def nice_election_name(self):
1✔
194
        name = self.name
1✔
195
        if not self.any_non_by_elections:
1✔
196
            name = name.replace("elections", "")
1✔
197
            name = name.replace("election", "")
1✔
198
            name = name.replace("UK Parliament", "UK Parliamentary")
1✔
199
            name = "{} {}".format(name, "by-election")
1✔
200

201
        return name
1✔
202

203
    @property
1✔
204
    def name_without_brackets(self):
1✔
205
        """
206
        Removes any characters from the election name after an opening bracket
207
        TODO name this see if we can do this more reliably based on data from
208
        EE
209
        """
210
        regex = r"\(.*?\)"
1✔
211
        brackets_removed = re.sub(regex, "", self.nice_election_name)
1✔
212
        # remove any extra whitespace
213
        return brackets_removed.replace("  ", " ").strip()
1✔
214

215
    def _election_datetime_tz(self):
1✔
216
        election_date = self.election_date
1✔
217
        election_datetime = datetime.datetime.fromordinal(
1✔
218
            election_date.toordinal()
219
        )
220
        election_datetime.replace(tzinfo=LOCAL_TZ)
1✔
221
        return election_datetime
1✔
222

223
    @property
1✔
224
    def start_time(self):
1✔
225
        election_datetime = self._election_datetime_tz()
1✔
226
        return election_datetime.replace(hour=7)
1✔
227

228
    @property
1✔
229
    def end_time(self):
1✔
230
        election_datetime = self._election_datetime_tz()
1✔
231
        return election_datetime.replace(hour=22)
1✔
232

233
    def get_absolute_url(self):
1✔
234
        return reverse(
1✔
235
            "election_view", args=[str(self.slug), slugify(self.name)]
236
        )
237

238
    def election_booklet(self):
1✔
239
        s3_bucket = (
1✔
240
            "https://wcivf-mayoral-booklets.s3.eu-west-2.amazonaws.com/booklets"
241
        )
242
        election_to_booklet = {
1✔
243
            "mayor.greater-manchester-ca.2017-05-04": f"{s3_bucket}/2017-05-04/mayoral/mayor.greater-manchester-ca.2017-05-04.pdf",
244
            "mayor.liverpool-city-ca.2017-05-04": f"{s3_bucket}/2017-05-04/mayoral/mayor.liverpool-city-ca.2017-05-04.pdf",
245
            "mayor.cambridgeshire-and-peterborough.2017-05-04": f"{s3_bucket}/2017-05-04/mayoral/mayor.cambridgeshire-and-peterborough.2017-05-04.pdf",
246
            "mayor.west-of-england.2017-05-04": f"{s3_bucket}/2017-05-04/mayoral/mayor.west-of-england.2017-05-04.pdf",
247
            "mayor.west-midlands.2017-05-04": f"{s3_bucket}/2017-05-04/mayoral/mayor.west-midlands.2017-05-04.pdf",
248
            "mayor.tees-valley.2017-05-04": f"{s3_bucket}/2017-05-04/mayoral/mayor.tees-valley.2017-05-04.pdf",
249
            "mayor.north-tyneside.2017-05-04": f"{s3_bucket}/2017-05-04/mayoral/mayor.north-tyneside.2017-05-04.pdf",
250
            "mayor.doncaster.2017-05-04": f"{s3_bucket}/2017-05-04/mayoral/mayor.doncaster.2017-05-04.pdf",
251
            "mayor.hackney.2018-05-03": f"{s3_bucket}/2018-05-03/mayoral/mayor.hackney.2018-05-03.pdf",
252
            "mayor.sheffield-city-ca.2018-05-03": f"{s3_bucket}/2018-05-03/mayoral/mayor.sheffield-city-ca.2018-05-03.pdf",
253
            "mayor.lewisham.2018-05-03": f"{s3_bucket}/2018-05-03/mayoral/mayor.lewisham.2018-05-03.pdf",
254
            "mayor.tower-hamlets.2018-05-03": f"{s3_bucket}/2018-05-03/mayoral/mayor.tower-hamlets.2018-05-03.pdf",
255
            "mayor.newham.2018-05-03": f"{s3_bucket}/2018-05-03/mayoral/mayor.newham.2018-05-03.pdf",
256
            "mayor.bristol.2021-05-06": f"{s3_bucket}/2021-05-06/mayoral/mayor.bristol.2021-05-06.pdf",
257
            "mayor.cambridgeshire-and-peterborough.2021-05-06": f"{s3_bucket}/2021-05-06/mayoral/mayor.cambridgeshire-and-peterborough.2021-05-06.pdf",
258
            "mayor.doncaster.2021-05-06": f"{s3_bucket}/2021-05-06/mayoral/mayor.doncaster.2021-05-06.pdf",
259
            "mayor.greater-manchester-ca.2021-05-06": f"{s3_bucket}/2021-05-06/mayoral/mayor.greater-manchester-ca.2021-05-06.pdf",
260
            "mayor.liverpool-city-ca.2021-05-06": f"{s3_bucket}/2021-05-06/mayoral/mayor.liverpool-city-ca.2021-05-06.pdf",
261
            "mayor.london.2021-05-06": f"{s3_bucket}/2021-05-06/mayoral/mayor.london.2021-05-06.pdf",
262
            "mayor.north-tyneside.2021-05-06": f"{s3_bucket}/2021-05-06/mayoral/mayor.north-tyneside.2021-05-06.pdf",
263
            "mayor.salford.2021-05-06": f"{s3_bucket}/2021-05-06/mayoral/mayor.salford.2021-05-06.pdf",
264
            "mayor.tees-valley.2021-05-06": f"{s3_bucket}/2021-05-06/mayoral/mayor.tees-valley.2021-05-06.pdf",
265
            "mayor.west-midlands.2021-05-06": f"{s3_bucket}/2021-05-06/mayoral/mayor.west-midlands.2021-05-06.pdf",
266
            "mayor.west-of-england.2021-05-06": f"{s3_bucket}/2021-05-06/mayoral/mayor.west-of-england.2021-05-06.pdf",
267
            "mayor.west-yorkshire.2021-05-06": f"{s3_bucket}/2021-05-06/mayoral/mayor.west-yorkshire.2021-05-06.pdf",
268
            "mayor.croydon.2022-05-05": f"{s3_bucket}/2022-05-05/mayoral/mayor.croydon.2022-05-05.pdf",
269
            "mayor.hackney.2022-05-05": f"{s3_bucket}/2022-05-05/mayoral/mayor.hackney.2022-05-05.pdf",
270
            "mayor.lewisham.2022-05-05": f"{s3_bucket}/2022-05-05/mayoral/mayor.lewisham.2022-05-05.pdf",
271
            "mayor.newham.2022-05-05": f"{s3_bucket}/2022-05-05/mayoral/mayor.newham.2022-05-05.pdf",
272
            "mayor.sheffield-city-ca.2022-05-05": f"{s3_bucket}/2022-05-05/mayoral/mayor.sheffield-city-ca.2022-05-05.pdf",
273
            "mayor.tower-hamlets.2022-05-05": f"{s3_bucket}/2022-05-05/mayoral/mayor.tower-hamlets.2022-05-05.pdf",
274
            "mayor.hackney.by.2023-11-09": f"{s3_bucket}/2023-11-09/mayoral/mayor.hackney.2023-11-09.pdf",
275
            "mayor.lewisham.2024-03-07": f"{s3_bucket}/2024-03-07/mayoral/lewisham.mayor.2024-03-07.pdf",
276
            "mayor.london.2024-05-02": f"{s3_bucket}/2024-05-02/mayoral/mayor.london.2024-05-02.pdf",
277
            "mayor.tees-valley.2024-05-02": f"{s3_bucket}/2024-05-02/mayoral/mayor.tees-valley.2024-05-02.pdf",
278
            "mayor.west-yorkshire.2024-05-02": f"{s3_bucket}/2024-05-02/mayoral/mayor.west-yorkshire.2024-05-02.pdf",
279
            "mayor.york-and-north-yorkshire-ca.2024-05-02": f"{s3_bucket}/2024-05-02/mayoral/mayor.york-and-north-yorkshire-ca.2024-05-02.pdf",
280
            "mayor.liverpool-city-ca.2024-05-02": f"{s3_bucket}/2024-05-02/mayoral/mayor.liverpool-city-ca.2024-05-02.pdf",
281
            "mayor.north-east-ca.2024-05-02": f"{s3_bucket}/2024-05-02/mayoral/mayor.north-east-ca.2024-05-02.pdf",
282
            "mayor.greater-manchester-ca.2024-05-02": f"{s3_bucket}/2024-05-02/mayoral/mayor.greater-manchester-ca.2024-05-02.pdf",
283
            "mayor.sheffield-city-ca.2024-05-02": f"{s3_bucket}/2024-05-02/mayoral/mayor.sheffield-city-ca.2024-05-02.pdf",
284
            "mayor.salford.2024-05-02": f"{s3_bucket}/2024-05-02/mayoral/mayor.salford.2024-05-02.pdf",
285
            "mayor.west-midlands.2024-05-02": f"{s3_bucket}/2024-05-02/mayoral/mayor.west-midlands.2024-05-02.pdf",
286
            "mayor.east-midlands-cca.2024-05-02": f"{s3_bucket}/2024-05-02/mayoral/mayor.east-midlands-cca.2024-05-02.pdf",
287
            "mayor.cambridgeshire-and-peterborough.2025-05-01": f"{s3_bucket}/2025-05-01/mayoral/mayor.cambridgeshire-and-peterborough.2025-05-01.pdf",
288
            "mayor.hull-and-east-yorkshire-ca.2025-05-01": f"{s3_bucket}/2025-05-01/mayoral/mayor.hull-and-east-yorkshire-ca.2025-05-01.pdf",
289
            "mayor.west-of-england.2025-05-01": f"{s3_bucket}/2025-05-01/mayoral/mayor.west-of-england.2025-05-01.pdf",
290
            "mayor.greater-lincolnshire-cca.2025-05-01": f"{s3_bucket}/2025-05-01/mayoral/mayor.greater-lincolnshire-cca.2025-05-01.pdf",
291
            "mayor.doncaster.2025-05-01": f"{s3_bucket}/2025-05-01/mayoral/mayor.doncaster.2025-05-01.pdf",
292
            "mayor.north-tyneside.2025-05-01": f"{s3_bucket}/2025-05-01/mayoral/mayor.north-tyneside.2025-05-01.pdf",
293
        }
294

295
        return election_to_booklet.get(self.slug)
1✔
296

297
    @property
1✔
298
    def ynr_link(self):
1✔
299
        return "{}/election/{}/constituencies?{}".format(
×
300
            settings.YNR_BASE, self.slug, settings.YNR_UTM_QUERY_STRING
301
        )
302

303
    @cached_property
1✔
304
    def pluralized_division_name(self):
1✔
305
        """
306
        Returns a string for the pluralised divison name for the posts in the
307
        election
308
        """
309
        first_post = self.post_set.first()
1✔
310
        if not first_post:
1✔
311
            return "posts"
×
312

313
        pluralise = {
1✔
314
            "parish": "parishes",
315
            "constituency": "constituencies",
316
        }
317
        suffix = first_post.division_suffix
1✔
318

319
        if not suffix:
1✔
320
            return "posts"
1✔
321

322
        return pluralise.get(suffix, f"{suffix}s")
1✔
323

324

325
class TerritoryCode(models.TextChoices):
1✔
326
    ENG = ("ENG", "England")
1✔
327
    NIR = ("NIR", "Northern Ireland")
1✔
328
    SCT = ("SCT", "Scotland")
1✔
329
    WLS = ("WLS", "Wales")
1✔
330

331

332
class Post(models.Model):
1✔
333
    """
334
    A post has an election and candidates
335
    """
336

337
    DIVISION_TYPE_CHOICES = [
1✔
338
        ("CED", "County Electoral Division"),
339
        ("COP", "Isles of Scilly Parish"),
340
        ("DIW", "District Ward"),
341
        ("EUR", "European Parliament Region"),
342
        ("LAC", "London Assembly Constituency"),
343
        ("LBW", "London Borough Ward"),
344
        ("LGE", "NI Electoral Area"),
345
        ("MTW", "Metropolitan District Ward"),
346
        ("NIE", "NI Assembly Constituency"),
347
        ("SPC", "Scottish Parliament Constituency"),
348
        ("SPE", "Scottish Parliament Region"),
349
        ("UTE", "Unitary Authority Electoral Division"),
350
        ("UTW", "Unitary Authority Ward"),
351
        ("WAC", "Welsh Assembly Constituency"),
352
        ("WAE", "Welsh Assembly Region"),
353
        ("WMC", "Westminster Parliamentary Constituency"),
354
    ]
355

356
    ynr_id = models.CharField(max_length=100, primary_key=True)
1✔
357
    label = models.CharField(blank=True, max_length=255)
1✔
358
    role = models.CharField(blank=True, max_length=255)
1✔
359
    group = models.CharField(blank=True, max_length=100)
1✔
360
    organization = models.CharField(blank=True, max_length=100)
1✔
361
    organization_type = models.CharField(blank=True, max_length=100)
1✔
362
    area_name = models.CharField(blank=True, max_length=100)
1✔
363
    area_id = models.CharField(blank=True, max_length=100)
1✔
364
    territory = models.CharField(
1✔
365
        blank=False,
366
        max_length=3,
367
        choices=TerritoryCode.choices,
368
        verbose_name="Territory",
369
    )
370
    elections = models.ManyToManyField(
1✔
371
        Election, through="elections.PostElection"
372
    )
373
    division_type = models.CharField(
1✔
374
        blank=True, max_length=3, choices=DIVISION_TYPE_CHOICES
375
    )
376

377
    def __str__(self) -> str:
1✔
378
        return f"{self.label} ({self.ynr_id})"
×
379

380
    def nice_organization(self):
1✔
381
        return (
1✔
382
            self.organization.replace(" County Council", "")
383
            .replace(" Borough Council", "")
384
            .replace(" District Council", "")
385
            .replace("London Borough of ", "")
386
            .replace(" Council", "")
387
        )
388

389
    def nice_territory(self):
1✔
390
        if self.territory == "WLS":
1✔
391
            return "Wales"
×
392

393
        if self.territory == "ENG":
1✔
394
            return "England"
1✔
395

396
        if self.territory == "SCT":
×
397
            return "Scotland"
×
398

399
        if self.territory == "NIR":
×
400
            return "Northern Ireland"
×
401

402
        return self.territory
×
403

404
    @property
1✔
405
    def division_description(self):
1✔
406
        """
407
        Return a string to describe the division.
408
        """
409
        mapping = {
1✔
410
            choice[0]: choice[1] for choice in self.DIVISION_TYPE_CHOICES
411
        }
412
        return mapping.get(self.division_type, "")
1✔
413

414
    @property
1✔
415
    def division_suffix(self):
1✔
416
        """
417
        Returns last word of the division_description
418
        """
419
        description = self.division_description
1✔
420
        if not description:
1✔
421
            return ""
1✔
422
        return description.split(" ")[-1].lower()
1✔
423

424
    @property
1✔
425
    def full_label(self):
1✔
426
        """
427
        Returns label with division suffix
428
        """
429
        return f"{self.label} {self.division_suffix}".strip()
1✔
430

431

432
class PostElectionQuerySet(models.QuerySet):
1✔
433
    def last_updated_in_ynr(self):
1✔
434
        """
435
        Returns the ballot with the most recent change made in YNR we know about
436
        """
437
        return self.filter(ynr_modified__isnull=False).latest("ynr_modified")
×
438

439
    def modified_gt_with_related(self, date):
1✔
440
        """
441
        Finds related models that have been updated
442
        since a given date and returns a queryset of PostElections
443
        """
444
        return (
1✔
445
            self.annotate(
446
                last_updated=Greatest(
447
                    "modified",
448
                    "husting__modified",
449
                    "localparty__modified",
450
                    output_field=DateTimeField(),
451
                ),
452
            )
453
            .filter(last_updated__gt=date)
454
            .order_by("last_updated")
455
        )
456

457
    def home_page_upcoming_ballots(self):
1✔
458
        """
459
        Returns a queryset of ballots to show on the home page
460

461
        """
462
        today = datetime.datetime.today()
1✔
463
        delta = datetime.timedelta(weeks=4)
1✔
464
        cut_off_date = today + delta
1✔
465
        return (
1✔
466
            self.filter(
467
                election__election_date__gte=today,
468
                election__election_date__lte=cut_off_date,
469
            )
470
            .filter(
471
                Q(election__any_non_by_elections=False)
472
                | Q(replaces__isnull=False)
473
                | Q(ballot_paper_id__startswith="ref.")
474
            )
475
            .select_related("election", "post")
476
            .order_by("election__election_date")
477
        )
478

479

480
class PostElection(TimeStampedModel):
1✔
481
    ballot_paper_id = models.CharField(blank=True, max_length=800, unique=True)
1✔
482
    post = models.ForeignKey(Post, on_delete=models.CASCADE)
1✔
483
    election = models.ForeignKey(Election, on_delete=models.CASCADE)
1✔
484
    contested = models.BooleanField(default=True)
1✔
485
    winner_count = models.IntegerField(blank=True, default=1)
1✔
486
    locked = models.BooleanField(default=False)
1✔
487
    cancelled = models.BooleanField(default=False)
1✔
488
    replaced_by = models.ForeignKey(
1✔
489
        "PostElection",
490
        null=True,
491
        blank=True,
492
        related_name="replaces",
493
        on_delete=models.CASCADE,
494
    )
495
    metadata = JSONField(null=True, blank=True)
1✔
496
    voting_system = models.ForeignKey(
1✔
497
        "VotingSystem", null=True, blank=True, on_delete=models.CASCADE
498
    )
499
    wikipedia_url = models.CharField(blank=True, null=True, max_length=800)
1✔
500
    wikipedia_bio = models.TextField(null=True, blank=True)
1✔
501
    ynr_modified = models.DateTimeField(
1✔
502
        blank=True,
503
        null=True,
504
        help_text="Timestamp of when this ballot was updated in the YNR",
505
    )
506
    requires_voter_id = models.CharField(blank=True, null=True, max_length=50)
1✔
507
    cancellation_reason = models.CharField(
1✔
508
        max_length=16,
509
        null=True,
510
        blank=True,
511
        choices=ElectionCancellationReason.choices,
512
        default=None,
513
    )
514
    by_election_reason = models.CharField(
1✔
515
        max_length=30,
516
        null=False,
517
        blank=True,
518
        choices=ByElectionReason.choices,
519
        default=ByElectionReason.NOT_APPLICABLE,
520
    )
521
    ballot_papers_issued = models.IntegerField(blank=True, null=True)
1✔
522
    electorate = models.IntegerField(blank=True, null=True)
1✔
523
    turnout = models.IntegerField(blank=True, null=True)
1✔
524
    spoilt_ballots = models.IntegerField(blank=True, null=True)
1✔
525

526
    objects = PostElectionQuerySet.as_manager()
1✔
527

528
    class Meta:
1✔
529
        get_latest_by = "ynr_modified"
1✔
530

531
    def __str__(self):
1✔
532
        return self.ballot_paper_id
×
533

534
    @property
1✔
535
    def has_results(self):
1✔
536
        """
537
        Returns a boolean for if the election has results
538
        """
539
        return bool(
1✔
540
            self.spoilt_ballots
541
            or self.ballot_papers_issued
542
            or self.turnout
543
            or self.electorate
544
            or self.personpost_set.filter(elected=True)
545
        )
546

547
    @property
1✔
548
    def expected_sopn_date(self):
1✔
549
        try:
1✔
550
            return get_election_timetable(
1✔
551
                self.ballot_paper_id, self.post.territory
552
            ).sopn_publish_date
UNCOV
553
        except (AttributeError, NotImplementedError):
×
UNCOV
554
            return None
×
555

556
    @property
1✔
557
    def registration_deadline(self):
1✔
558
        try:
1✔
559
            date = get_election_timetable(
1✔
560
                self.ballot_paper_id, self.post.territory
561
            ).registration_deadline
UNCOV
562
        except AttributeError:
×
UNCOV
563
            return None
×
564

565
        return date.strftime("%d %B %Y")
1✔
566

567
    @property
1✔
568
    def past_registration_deadline(self):
1✔
569
        try:
1✔
570
            registration_deadline = get_election_timetable(
1✔
571
                self.ballot_paper_id, self.post.territory
572
            ).registration_deadline
573
        except AttributeError:
×
574
            return None
×
575

576
        return registration_deadline < datetime.date.today()
1✔
577

578
    @property
1✔
579
    def postal_vote_application_deadline(self):
1✔
580
        try:
1✔
581
            date = get_election_timetable(
1✔
582
                self.ballot_paper_id, self.post.territory
583
            ).postal_vote_application_deadline
UNCOV
584
        except AttributeError:
×
UNCOV
585
            return None
×
586

587
        return date.strftime("%d %B %Y")
1✔
588

589
    @property
1✔
590
    def past_vac_application_deadline(self):
1✔
UNCOV
591
        try:
×
UNCOV
592
            vac_application_deadline = get_election_timetable(
×
593
                self.ballot_paper_id, self.post.territory
594
            ).vac_application_deadline
UNCOV
595
        except AttributeError:
×
UNCOV
596
            return None
×
597

UNCOV
598
        return vac_application_deadline < datetime.date.today()
×
599

600
    @property
1✔
601
    def vac_application_deadline(self):
1✔
UNCOV
602
        try:
×
UNCOV
603
            return get_election_timetable(
×
604
                self.ballot_paper_id, self.post.territory
605
            ).vac_application_deadline
UNCOV
606
        except AttributeError:
×
UNCOV
607
            return None
×
608

609
    @property
1✔
610
    def postal_vote_requires_form(self):
1✔
611
        matcher = PostalVotingRequirementsMatcher(
1✔
612
            election_id=self.election.slug, nation=self.post.territory
613
        )
614

615
        voting_requirements_legislation = (
1✔
616
            matcher.get_postal_voting_requirements()
617
        )
618

619
        if voting_requirements_legislation == "EA-2022":
1✔
620
            return True
1✔
621
        return False
1✔
622

623
    @property
1✔
624
    def is_mayoral(self):
1✔
625
        """
626
        Return a boolean for if this is a mayoral election, determined by
627
        checking ballot paper id
628
        """
629
        return self.ballot_paper_id.startswith("mayor")
1✔
630

631
    @property
1✔
632
    def is_parliamentary(self):
1✔
633
        """
634
        Return a boolean for if this is a parliamentary election, determined by
635
        checking ballot paper id
636
        """
637
        return self.ballot_paper_id.startswith("parl")
1✔
638

639
    @property
1✔
640
    def is_london_assembly_additional(self):
1✔
641
        """
642
        Return a boolean for if this is a London Assembley additional ballot
643
        """
644
        return self.ballot_paper_id.startswith("gla.a")
1✔
645

646
    @property
1✔
647
    def is_pcc(self):
1✔
648
        """
649
        Return a boolean for if this is a PCC ballot
650
        """
651
        return self.ballot_paper_id.startswith("pcc")
1✔
652

653
    @property
1✔
654
    def is_constituency(self):
1✔
655
        if self.ballot_paper_id.startswith("senedd."):
1✔
656
            if self.ballot_paper_id.startswith("senedd.r."):
1✔
657
                return False
1✔
658
            return True
1✔
659
        if self.ballot_paper_id.startswith(("gla.c.", "senedd.c.", "sp.c.")):
1✔
660
            return True
1✔
661
        return False
1✔
662

663
    @property
1✔
664
    def is_regional(self):
1✔
665
        return self.ballot_paper_id.startswith(("senedd.r.", "sp.r."))
1✔
666

667
    @property
1✔
668
    def is_referendum(self):
1✔
669
        """
670
        Return a boolean for if this is a Referendum ballot
671
        """
672
        return self.ballot_paper_id.startswith("ref.")
1✔
673

674
    @property
1✔
675
    def is_postponed(self):
1✔
676
        return self.cancellation_reason in [
1✔
677
            "NO_CANDIDATES",
678
            "CANDIDATE_DEATH",
679
            "UNDER_CONTESTED",
680
        ]
681

682
    @property
1✔
683
    def is_uncontested(self):
1✔
684
        return self.cancellation_reason == "EQUAL_CANDIDATES"
1✔
685

686
    @property
1✔
687
    def friendly_name(self):
1✔
688
        """
689
        Helper property used in templates to build a 'friendly' name using
690
        details from associated Post object, with exceptions for mayoral and
691
        Police and Crime Commissioner elections
692
        """
693
        if self.is_mayoral:
1✔
694
            return (
1✔
695
                f"{self.post.full_label} mayoral election"
696
                + self.cancellation_suffix
697
            )
698

699
        if self.is_pcc:
1✔
700
            label = self.post.full_label.replace(" Police", "")
1✔
701
            return f"{label} Police force area" + self.cancellation_suffix
1✔
702

703
        ballot_label = self.post.full_label
1✔
704
        if ".by." in self.ballot_paper_id:
1✔
705
            ballot_label = _(f"{ballot_label} by-election")
1✔
706

707
        return f"{ballot_label} {self.cancellation_suffix}".strip()
1✔
708

709
    def get_absolute_url(self):
1✔
710
        if self.ballot_paper_id.startswith("tmp_"):
1✔
UNCOV
711
            return reverse("home_view")
×
712
        return reverse(
1✔
713
            "election_view",
714
            args=[str(self.ballot_paper_id), slugify(self.post.label)],
715
        )
716

717
    @property
1✔
718
    def ynr_link(self):
1✔
719
        return "{}/elections/{}?{}".format(
1✔
720
            settings.YNR_BASE,
721
            self.ballot_paper_id,
722
            settings.YNR_UTM_QUERY_STRING,
723
        )
724

725
    @property
1✔
726
    def ynr_sopn_link(self):
1✔
UNCOV
727
        return "{}/elections/{}/sopn/?{}".format(
×
728
            settings.YNR_BASE,
729
            self.ballot_paper_id,
730
            settings.YNR_UTM_QUERY_STRING,
731
        )
732

733
    @property
1✔
734
    def cancellation_suffix(self):
1✔
735
        if not self.cancelled:
1✔
736
            return ""
1✔
737
        if not self.cancellation_reason:
1✔
738
            # We don't really know what's going on here
739
            # so let's assume it's postponed.
740
            return _(" (postponed)")
1✔
741

742
        if self.is_postponed:
1✔
743
            return _(" (postponed)")
1✔
744

745
        if self.is_uncontested:
1✔
746
            return _(" (uncontested)")
1✔
747

748
        # If we've got here we don't really know what's going on. Return nothing
749
        # to be safe.
UNCOV
750
        return ""
×
751

752
    @property
1✔
753
    def short_cancelled_message_html(self):
1✔
754
        if not self.cancelled:
1✔
755
            return ""
1✔
756
        message = None
1✔
757

758
        if self.cancellation_reason:
1✔
759
            if self.cancellation_reason == "CANDIDATE_DEATH":
1✔
760
                message = """<strong> ❌ This election has been cancelled due to the death of a candidate.</strong>"""
1✔
761
            else:
762
                message = """<strong> ❌ The poll for this election will not take place because it is uncontested.</strong>"""
1✔
763
        else:
764
            # Leaving this in for now as we transition away from metadata
765
            if self.metadata and self.metadata.get("cancelled_election"):
1✔
UNCOV
766
                title = self.metadata["cancelled_election"].get("title")
×
UNCOV
767
                url = self.metadata["cancelled_election"].get("url")
×
UNCOV
768
                message = title
×
UNCOV
769
                if url:
×
770
                    message = (
×
771
                        """<strong> ❌ <a href="{}">{}</a></strong>""".format(
772
                            url, title
773
                        )
774
                    )
775
        if not message:
1✔
776
            if self.election.in_past:
1✔
777
                message = "(The poll for this election was cancelled)"
1✔
778
            else:
UNCOV
779
                message = "<strong>(The poll for this election has been cancelled)</strong>"
×
780

781
        return mark_safe(message)
1✔
782

783
    @property
1✔
784
    def by_election_reason_text(self):
1✔
785
        if self.by_election_reason in [
1✔
786
            ByElectionReason.UNKNOWN,
787
            ByElectionReason.OTHER,
788
            ByElectionReason.NOT_APPLICABLE,
789
        ]:
790
            return ""
1✔
791

792
        return _("This by-election was called because %(reason)s.") % {
1✔
793
            "reason": self.get_by_election_reason_display().lower()
794
        }
795

796
    @property
1✔
797
    def get_voting_system(self):
1✔
798
        if self.voting_system:
1✔
799
            return self.voting_system
×
800

801
        return self.election.voting_system
1✔
802

803
    @property
1✔
804
    def display_as_party_list(self):
1✔
805
        if (
1✔
806
            self.get_voting_system
807
            and self.get_voting_system.slug in settings.PARTY_LIST_VOTING_TYPES
808
        ):
UNCOV
809
            return True
×
810
        return False
1✔
811

812
    @cached_property
1✔
813
    def next_ballot(self):
1✔
814
        """
815
        Return the next ballot for the related post. Return None if this is
816
        the current election to avoid making an unnecessary db query.
817
        """
818
        if self.election.current:
1✔
819
            return None
1✔
820

821
        try:
1✔
822
            return self.post.postelection_set.filter(
1✔
823
                election__election_date__gt=self.election.election_date,
824
                election__election_date__gte=datetime.date.today(),
825
                election__election_type=self.election.election_type,
826
            ).latest("election__election_date")
827
        except PostElection.DoesNotExist:
1✔
828
            return None
1✔
829

830
    @property
1✔
831
    def party_ballot_count(self):
1✔
832
        if self.personpost_set.exists():
1✔
833
            people = self.personpost_set
1✔
834
            if self.election.uses_lists:
1✔
835
                ind_candidates = people.filter(party_id="ynmp-party:2").count()
1✔
836
                num_other_parties = (
1✔
837
                    people.exclude(party_id="ynmp-party:2")
838
                    .values("party_id")
839
                    .distinct()
840
                    .count()
841
                )
842
                ind_and_parties = ind_candidates + num_other_parties
1✔
843
                ind_and_parties_apnumber = apnumber(ind_and_parties)
1✔
844
                ind_and_parties_pluralized = pluralize(ind_and_parties)
1✔
845
                value = f"{ind_and_parties_apnumber} parties"
1✔
846
                if ind_candidates:
1✔
847
                    value = f"{value} or independent candidate{ind_and_parties_pluralized}"
1✔
848
                return value
1✔
849

850
            num_candidates = people.count()
1✔
851
            candidates_apnumber = apnumber(num_candidates)
1✔
852
            candidates_pluralized = pluralize(num_candidates)
1✔
853
            return f"{candidates_apnumber} candidate{candidates_pluralized}"
1✔
854

UNCOV
855
        return None
×
856

857
    @property
1✔
858
    def should_display_sopn_info(self):
1✔
859
        """
860
        Return boolean for whether to display text about SOPN
861
        """
862
        if self.election.in_past:
1✔
863
            return False
1✔
864

865
        if self.locked:
1✔
866
            return True
1✔
867

868
        return bool(self.expected_sopn_date)
1✔
869

870
    @property
1✔
871
    def past_expected_sopn_day(self):
1✔
872
        """
873
        Return boolean for the date we expected the sopn date
874
        """
875
        return self.expected_sopn_date <= timezone.now().date()
1✔
876

877
    @property
1✔
878
    def should_show_candidates(self):
1✔
879
        if not self.cancelled:
1✔
880
            return True
1✔
881
        if self.cancellation_reason in ["CANDIDATE_DEATH"]:
1✔
UNCOV
882
            return False
×
883
        if not self.metadata:
1✔
884
            return True
1✔
UNCOV
885
        return True
×
886

887
    @property
1✔
888
    def get_postal_voting_requirements(self):
1✔
889
        try:
1✔
890
            matcher = PostalVotingRequirementsMatcher(
1✔
891
                self.ballot_paper_id, nation=self.post.territory
892
            )
893
            return matcher.get_postal_voting_requirements()
1✔
UNCOV
894
        except Exception:
×
UNCOV
895
            return None
×
896

897

898
class VotingSystem(models.Model):
1✔
899
    slug = models.SlugField(primary_key=True)
1✔
900
    name = models.CharField(blank=True, max_length=100)
1✔
901
    wikipedia_url = models.URLField(blank=True)
1✔
902
    description = models.TextField(blank=True)
1✔
903

904
    def __str__(self):
1✔
905
        return self.name
×
906

907
    @property
1✔
908
    def uses_party_lists(self):
1✔
UNCOV
909
        return self.slug in ["PR-CL", "AMS"]
×
910

911
    @property
1✔
912
    def get_absolute_url(self):
1✔
UNCOV
913
        if self.slug == "FPTP":
×
914
            return reverse("fptp_voting_system_view")
×
915
        if self.slug == "AMS":
×
UNCOV
916
            return reverse("ams_voting_system_view")
×
UNCOV
917
        if self.slug == "sv":
×
UNCOV
918
            return reverse("sv_voting_system_view")
×
UNCOV
919
        if self.slug == "STV":
×
UNCOV
920
            return reverse("stv_voting_system_view")
×
UNCOV
921
        if self.slug == "PR-CL":
×
UNCOV
922
            return reverse("pr_cl_voting_system_view")
×
923

UNCOV
924
        return None
×
925

926
    @property
1✔
927
    def get_name(self):
1✔
UNCOV
928
        if self.slug == "FPTP":
×
929
            return _("First-past-the-post")
×
UNCOV
930
        if self.slug == "AMS":
×
UNCOV
931
            return _("Additional Member System")
×
UNCOV
932
        if self.slug == "sv":
×
933
            return _("Supplementary Vote")
×
934
        if self.slug == "STV":
×
935
            return _("Single Transferable Vote")
×
936
        if self.slug == "PR-CL":
×
937
            return _("Closed-List Proportional Representation")
×
938

939
        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