• 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

80.97
/wcivf/apps/elections/views/postcode_view.py
1
from typing import Optional
1✔
2

3
from administrations.helpers import AdministrationsHelper
1✔
4
from core.helpers import clean_postcode
1✔
5
from django.conf import settings
1✔
6
from django.http import HttpResponse, HttpResponseRedirect
1✔
7
from django.utils import timezone
1✔
8
from django.views.generic import TemplateView, View
1✔
9
from elections.dummy_models import DummyPostElection, dummy_polling_station
1✔
10
from elections.models import LOCAL_TZ, InvalidPostcodeError
1✔
11
from icalendar import Calendar, Event, vText
1✔
12
from parishes.models import ParishCouncilElection
1✔
13

14
from ..devs_dc_client import DevsDCAPIException
1✔
15
from .mixins import (
1✔
16
    LogLookUpMixin,
17
    NewSlugsRedirectMixin,
18
    PollingStationInfoMixin,
19
    PostcodeToPostsMixin,
20
    PostelectionsToPeopleMixin,
21
)
22

23

24
class PostcodeView(
1✔
25
    NewSlugsRedirectMixin,
26
    PostcodeToPostsMixin,
27
    PollingStationInfoMixin,
28
    LogLookUpMixin,
29
    TemplateView,
30
    PostelectionsToPeopleMixin,
31
):
32
    """
33
    This is the main view that takes a postcode and shows all elections
34
    for that area, with related information.
35

36
    This is really the main destination page of the whole site, so there is a
37
    high chance this will need to be split out in to a few mixins, and cached
38
    well.
39
    """
40

41
    template_name = "elections/postcode_view.html"
1✔
42
    pk_url_kwarg = "postcode"
1✔
43
    ballot_dict = None
1✔
44
    postcode = None
1✔
45
    uprn = None
1✔
46
    parish_council_election = None
1✔
47

48
    def get_ballot_dict(self):
1✔
49
        """
50
        Returns a QuerySet of PostElection objects. Calls postcode_to_ballots
51
        and updates the self.ballot_dict attribute the first time it is called.
52
        """
53
        if self.ballot_dict is None:
1✔
54
            self.ballot_dict = self.postcode_to_ballots(
1✔
55
                postcode=self.postcode, uprn=self.uprn
56
            )
57

58
        return self.ballot_dict
1✔
59

60
    def get_context_data(self, **kwargs):
1✔
61
        context = super().get_context_data(**kwargs)
1✔
62
        self.postcode = clean_postcode(kwargs["postcode"])
1✔
63
        self.uprn = self.kwargs.get("uprn")
1✔
64

65
        context["postcode"] = self.postcode
1✔
66

67
        try:
1✔
68
            ballot_dict = self.get_ballot_dict()
1✔
69
            context["address_picker"] = ballot_dict.get("address_picker")
1✔
70
            context["addresses"] = ballot_dict.get("addresses")
1✔
71
        except (InvalidPostcodeError, DevsDCAPIException) as exception:
1✔
72
            raise exception
1✔
73

74
        if (
1✔
75
            not context["address_picker"]
76
            and settings.ENABLE_LAYERS_OF_STATE_FEATURE
77
        ):
UNCOV
78
            try:
×
79
                administrations = AdministrationsHelper(
×
80
                    self.postcode, uprn=self.uprn
81
                )
82
                context["administrations"] = administrations
×
83
                if administrations.address_picker:
×
UNCOV
84
                    context["address_picker"] = True
×
85
                    context["addresses"] = administrations.addresses
×
UNCOV
86
            except Exception:
×
87
                # Just catch any error at the moment, as we don't want this to break anything
UNCOV
88
                pass
×
89

90
        self.log_postcode(self.postcode)
1✔
91

92
        if context["address_picker"]:
1✔
UNCOV
93
            return context
×
94

95
        context["postelections"] = ballot_dict.get("ballots")
1✔
96
        context["future_postelections"] = self.future_postelections(
1✔
97
            context["postelections"]
98
        )
99
        context["show_polling_card"] = self.show_polling_card(
1✔
100
            context["postelections"]
101
        )
102
        context["global_registration_card"] = self.get_global_registration_card(
1✔
103
            context["postelections"]
104
        )
105
        context["people_for_post"] = {}
1✔
106
        for postelection in context["postelections"]:
1✔
107
            postelection.people = self.people_for_ballot(postelection)
1✔
108
        context["polling_station"] = self.ballot_dict.get("polling_station")
1✔
109
        context[
1✔
110
            "polling_station_opening_times"
111
        ] = self.get_polling_station_opening_times()
112
        context["council"] = self.ballot_dict.get("electoral_services")
1✔
113
        context["registration"] = self.ballot_dict.get("registration")
1✔
114
        context["postcode_location"] = self.ballot_dict.get(
1✔
115
            "postcode_location", None
116
        )
117

118
        context[
1✔
119
            "advance_voting_station"
120
        ] = self.get_advance_voting_station_info(context["polling_station"])
121

122
        context["ballots_today"] = self.get_todays_ballots()
1✔
123
        context[
1✔
124
            "multiple_city_of_london_elections_on_next_poll_date"
125
        ] = self.multiple_city_of_london_elections_on_next_poll_date()
126
        context["referendums"] = list(self.get_referendums())
1✔
127
        context["parish_council_election"] = self.get_parish_council_election()
1✔
128
        context["num_ballots"] = self.num_ballots()
1✔
129
        context["requires_voter_id"] = self.get_voter_id_status()
1✔
130
        context["show_parish_text"] = self.show_parish_text(context["council"])
1✔
131

132
        return context
1✔
133

134
    def future_postelections(self, postelections):
1✔
135
        """
136
        Given a list of postelections, check if any of them are in the future
137
        and return True if so.
138
        """
139
        return any(
1✔
140
            postelection
141
            for postelection in postelections
142
            if not postelection.election.in_past
143
        )
144

145
    def get_todays_ballots(self):
1✔
146
        """
147
        Return a list of ballots filtered by whether they are today
148
        """
149
        return [
1✔
150
            ballot
151
            for ballot in self.ballot_dict.get("ballots")
152
            if ballot.election.is_election_day
153
        ]
154

155
    def get_referendums(self):
1✔
156
        """
157
        Yield all referendums associated with the ballots for this postcode.
158
        After 6th May return an empty list to avoid displaying unwanted
159
        information
160
        """
161
        if (
1✔
162
            timezone.datetime.today().date()
163
            > timezone.datetime(2021, 5, 6).date()
164
        ):
165
            return []
1✔
166

167
        for ballot in self.ballot_dict.get("ballots", []):
1✔
168
            yield from ballot.referendums.all()
1✔
169

170
    def get_ballots_for_next_date(self):
1✔
171
        ballots = (
1✔
172
            self.get_ballot_dict()
173
            .get("ballots")
174
            .order_by("election__election_date")
175
        )
176
        if not ballots:
1✔
177
            return ballots
1✔
178
        first_ballot_date = ballots[0].election.election_date
1✔
179
        return ballots.filter(election__election_date=first_ballot_date)
1✔
180

181
    def get_polling_station_opening_times(self):
1✔
182
        ballots = self.get_ballots_for_next_date()
1✔
183
        if not ballots:
1✔
184
            return {
1✔
185
                "polls_open": None,
186
                "polls_close": None,
187
            }
188
        for ballot in ballots:
1✔
189
            if ballot.election.is_city_of_london_local_election:
1✔
190
                return {
1✔
191
                    "polls_open": ballot.election.polls_open,
192
                    "polls_close": ballot.election.polls_close,
193
                }
194
        return {
1✔
195
            "polls_open": ballots[0].election.polls_open,
196
            "polls_close": ballots[0].election.polls_close,
197
        }
198

199
    def multiple_city_of_london_elections_on_next_poll_date(self):
1✔
200
        """
201
        Checks if there are multiple elections taking place today in the City
202
        of London. This is used to determine if it is safe to display polling
203
        station open/close times in the template. As if there are multiple then
204
        it is unclear what time the polls would be open. See this issue for
205
        more info https://github.com/DemocracyClub/WhoCanIVoteFor/issues/441
206
        """
207
        ballots = self.get_ballots_for_next_date()
1✔
208

209
        # if only one ballot can return early
210
        if len(ballots) <= 1:
1✔
211
            return False
1✔
212

213
        if not any(
1✔
214
            ballot
215
            for ballot in ballots
216
            if ballot.election.is_city_of_london_local_election
217
            or ballot.election.election_covers_city_of_london
218
        ):
219
            return False
1✔
220

221
        # get unique elections and return whether more than 1
222
        return len({ballot.election.slug for ballot in ballots}) > 1
1✔
223

224
    def get_parish_council_election(self):
1✔
225
        """
226
        Check if we have any ballot_dict with a parish council, if not return an
227
        empty QuerySet. If we do, return the first object we find. This may seem
228
        arbritary to only use the first object we find but in practice we only
229
        assign a single parish council for to a single english local election
230
        ballot. So in practice we should only ever find one object.
231
        """
232
        if self.parish_council_election is not None:
1✔
233
            return self.parish_council_election
1✔
234
        if not self.ballot_dict.get("ballots"):
1✔
235
            return None
1✔
236

237
        ballots_with_parishes = self.ballot_dict.get("ballots").filter(
1✔
238
            num_parish_councils__gt=0
239
        )
240
        if not ballots_with_parishes:
1✔
241
            return None
1✔
242

243
        self.parish_council_election = ParishCouncilElection.objects.filter(
1✔
244
            ballots__in=self.ballot_dict["ballots"]
245
        ).first()
246
        return self.parish_council_election
1✔
247

248
    def num_ballots(self):
1✔
249
        """
250
        Calculate the number of ballot_dict there will be to fill in, accounting for
251
        the any parish council ballot_dict if a contested parish council election is
252
        taking place in the future
253
        """
254
        num_ballots = len(
1✔
255
            [
256
                ballot
257
                for ballot in self.ballot_dict.get("ballots")
258
                if not ballot.past_date
259
            ]
260
        )
261

262
        if not self.parish_council_election:
1✔
263
            return num_ballots
1✔
264

265
        if self.parish_council_election.in_past:
1✔
266
            return num_ballots
1✔
267

268
        if self.parish_council_election.is_contested:
1✔
269
            num_ballots += 1
1✔
270

271
        return num_ballots
1✔
272

273
    def get_voter_id_status(self) -> Optional[str]:
1✔
274
        """
275
        For a given election, determine whether any ballot_dict require photo ID
276
        If yes, return the stub value (e.g. EA-2022)
277
        If no, return None
278
        """
279
        for ballot in self.ballot_dict.get("ballots"):
1✔
280
            if not ballot.cancelled and (voter_id := ballot.requires_voter_id):
1✔
281
                return voter_id
1✔
282
        return None
1✔
283

284
    def show_parish_text(self, council):
1✔
285
        """
286
        Returns True if the postcode isn't in London and Northern Ireland. We don't want
287
        to show the parish council text in these areas because they don't have them.
288
        """
289
        # all NI postcodes start with BT
290
        if self.postcode.startswith("BT"):
1✔
291
            return False
1✔
292
        # All London borough GSS codes start with E09
293
        if any(
1✔
294
            identifier.startswith("E09")
295
            for identifier in council["identifiers"]
296
        ):
297
            return False
1✔
298
        return True
1✔
299

300

301
class PostcodeiCalView(
1✔
302
    NewSlugsRedirectMixin, PostcodeToPostsMixin, View, PollingStationInfoMixin
303
):
304
    pk_url_kwarg = "postcode"
1✔
305
    postcode = None
1✔
306

307
    def get(self, request, *args, **kwargs):
1✔
308
        self.postcode = kwargs["postcode"]
1✔
309
        uprn = kwargs.get("uprn")
1✔
310
        try:
1✔
311
            self.ballot_dict = self.postcode_to_ballots(
1✔
312
                postcode=self.postcode, uprn=uprn
313
            )
314
        except (InvalidPostcodeError, DevsDCAPIException) as exception:
1✔
315
            querystring = self.gen_querystring_from_exception(exception)
1✔
316
            return HttpResponseRedirect(f"/?{querystring}")
1✔
317

318
        polling_station = self.ballot_dict.get("polling_station")
1✔
319

320
        cal = Calendar()
1✔
321
        cal["summary"] = "Elections in {}".format(self.postcode)
1✔
322
        cal["X-WR-CALNAME"] = "Elections in {}".format(self.postcode)
1✔
323
        cal["X-WR-TIMEZONE"] = LOCAL_TZ.zone
1✔
324

325
        cal.add("version", "2.0")
1✔
326
        cal.add("prodid", "-//Elections in {}//mxm.dk//".format(self.postcode))
1✔
327

328
        # If we need the user to enter an address then we
329
        # need to add an event asking them to do this.
330
        # This is a bit of a hack, but there's no real other
331
        # way to tell the user about address pickers
332
        if self.ballot_dict.get("address_picker", False):
1✔
333
            event = Event()
1✔
334
            event["uid"] = f"{self.postcode}-address-picker"
1✔
335
            event["summary"] = "You may have upcoming elections"
1✔
336
            event.add("dtstamp", timezone.now())
1✔
337
            PostcodeiCalView.add_local_timestamp(
1✔
338
                event, "dtstart", timezone.now().date()
339
            )
340
            PostcodeiCalView.add_local_timestamp(
1✔
341
                event, "dtend", timezone.now().date()
342
            )
343
            event.add(
1✔
344
                "DESCRIPTION",
345
                (
346
                    f"In order to tell you about upcoming elections you need to"
347
                    f"pick your address from a list and update your calender feed URL"
348
                    f"Please visit https://whocanivotefor.co.uk/elections/{self.postcode}/, pick your"
349
                    f"address and then use the calendar URL on that page."
350
                ),
351
            )
352
            cal.add_component(event)
1✔
353
            return HttpResponse(cal.to_ical(), content_type="text/calendar")
1✔
354

355
        for post_election in self.ballot_dict["ballots"]:
1✔
356
            if post_election.cancelled:
1✔
UNCOV
357
                continue
×
358
            event = Event()
1✔
359
            event["uid"] = "{}-{}".format(
1✔
360
                post_election.post.ynr_id, post_election.election.slug
361
            )
362
            event["summary"] = "{} - {}".format(
1✔
363
                post_election.election.name, post_election.post.label
364
            )
365
            event.add("dtstamp", timezone.now())
1✔
366
            PostcodeiCalView.add_local_timestamp(
1✔
367
                event, "dtstart", post_election.election.start_time
368
            )
369
            PostcodeiCalView.add_local_timestamp(
1✔
370
                event, "dtend", post_election.election.end_time
371
            )
372
            event.add(
1✔
373
                "DESCRIPTION",
374
                "Find out more at {}/elections/{}/".format(
375
                    settings.CANONICAL_URL, self.postcode.replace(" ", "")
376
                ),
377
            )
378

379
            if polling_station.get("polling_station_known"):
1✔
380
                geometry = polling_station["station"]["geometry"]
1✔
381
                if geometry:
1✔
382
                    event["geo"] = "{};{}".format(
1✔
383
                        geometry["coordinates"][0], geometry["coordinates"][1]
384
                    )
385
                properties = polling_station["station"]["properties"]
1✔
386
                event["location"] = vText(
1✔
387
                    "{}, {}".format(
388
                        properties["address"].replace("\n", ", "),
389
                        properties["postcode"],
390
                    )
391
                )
392

393
            cal.add_component(event)
1✔
394

395
            # add hustings events if there are any in the future
396
            for husting in post_election.husting_set.published().future():
1✔
397
                event = Event()
×
398
                event["uid"] = husting.uuid
×
399
                event["summary"] = husting.title
×
400
                event.add("dtstamp", timezone.now())
×
UNCOV
401
                PostcodeiCalView.add_local_timestamp(
×
402
                    event, "dtstart", husting.starts
403
                )
404
                if husting.ends:
×
UNCOV
405
                    PostcodeiCalView.add_local_timestamp(
×
406
                        event, "dtend", husting.ends
407
                    )
408
                event.add("DESCRIPTION", f"Find out more at {husting.url}")
×
UNCOV
409
                cal.add_component(event)
×
410

411
        return HttpResponse(cal.to_ical(), content_type="text/calendar")
1✔
412

413
    @staticmethod
1✔
414
    def add_local_timestamp(event, name, value):
1✔
415
        event.add(name, value, {"TZID": LOCAL_TZ.zone})
1✔
416

417

418
class DummyPostcodeiCalView(PostcodeiCalView):
1✔
419
    def get(self, request, *args, **kwargs):
1✔
420
        kwargs["postcode"] = "TE1 1ST"
1✔
421
        return super().get(request, *args, **kwargs)
1✔
422

423
    def postcode_to_ballots(self, postcode, uprn=None, compact=False):
1✔
424
        return {
1✔
425
            "ballots": [DummyPostElection()],
426
            "polling_station": dummy_polling_station,
427
        }
428

429

430
class DummyPostcodeView(PostcodeView):
1✔
431
    postcode = None
1✔
432
    uprn = None
1✔
433

434
    def get(self, request, *args, **kwargs):
1✔
435
        kwargs["postcode"] = self.postcode
×
436
        context = self.get_context_data(**kwargs)
×
UNCOV
437
        return self.render_to_response(context)
×
438

439
    def get_context_data(self, **kwargs):
1✔
440
        context = kwargs
×
441
        self.postcode = clean_postcode(kwargs["postcode"])
×
442
        self.uprn = self.kwargs.get("uprn")
×
443
        context["uprn"] = self.uprn
×
444
        context["postcode"] = self.postcode
×
445
        self.uprn = self.kwargs.get("uprn")
×
UNCOV
446
        context["uprn"] = self.uprn
×
447

448
        context["postelections"] = self.get_ballot_dict()
×
UNCOV
449
        context["future_postelections"] = PostcodeView().future_postelections(
×
450
            context["postelections"]
451
        )
452
        context["show_polling_card"] = True
×
453
        context["polling_station"] = self.get_polling_station()
×
UNCOV
454
        context[
×
455
            "global_registration_card"
456
        ] = PostcodeView().get_global_registration_card(
457
            context["postelections"]
458
        )
459
        context["registration"] = self.get_registration()
×
460
        context["council"] = self.get_electoral_services()
×
461
        context["requires_voter_id"] = "EA-2022"
×
462
        context["num_ballots"] = 1
×
UNCOV
463
        return context
×
464

465
    def get_ballot_dict(self):
1✔
UNCOV
466
        return [DummyPostElection()]
×
467

468
    def get_electoral_services(self):
1✔
UNCOV
469
        return {
×
470
            "council_id": "W06000015",
471
            "name": "Cardiff Council",
472
            "nation": "Wales",
473
            "address": "Electoral Registration Officer\nCity of Cardiff Council\nCounty Hall Atlantic Wharf",
474
            "postcode": "CF10 4UW",
475
            "email": "electoralservices@cardiff.gov.uk",
476
            "phone": "029 2087 2034",
477
            "website": "http://www.cardiff.gov.uk/",
478
        }
479

480
    def get_registration(self):
1✔
UNCOV
481
        return {
×
482
            "address": "Electoral Registration Officer\nCity of Cardiff Council\nCounty Hall Atlantic Wharf",
483
            "postcode": "CF10 4UW",
484
            "email": "electoralservices@cardiff.gov.uk",
485
            "phone": "029 2087 2034",
486
            "website": "http://www.cardiff.gov.uk/",
487
        }
488

489
    def get_polling_station(self):
1✔
UNCOV
490
        return dummy_polling_station
×
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