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

DemocracyClub / yournextrepresentative / 331362b5-3b39-45b1-a177-b4ab59a18cd4

16 Mar 2026 07:54PM UTC coverage: 74.474% (-0.04%) from 74.511%
331362b5-3b39-45b1-a177-b4ab59a18cd4

Pull #2681

circleci

symroe
WIP confirmation page
Pull Request #2681: Remove existing candidates from bulk add flow

864 of 1220 branches covered (70.82%)

Branch coverage included in aggregate %.

96 of 100 new or added lines in 5 files covered. (96.0%)

22 existing lines in 2 files now uncovered.

7880 of 10521 relevant lines covered (74.9%)

0.75 hits per line

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

94.98
/ynr/apps/bulk_adding/forms.py
1
from bulk_adding.fields import (
1✔
2
    PersonIdentifierFieldSet,
3
    PersonSuggestionField,
4
    PersonSuggestionRadioSelect,
5
)
6
from django import forms
1✔
7
from django.core.exceptions import ValidationError
1✔
8
from django.db.models import (
1✔
9
    CharField,
10
    Exists,
11
    IntegerField,
12
    OuterRef,
13
    Prefetch,
14
    Value,
15
)
16
from parties.forms import (
1✔
17
    PartyIdentifierField,
18
    PopulatePartiesMixin,
19
    PreviousPartyAffiliationsField,
20
)
21
from parties.models import Party, PartyDescription
1✔
22
from people.forms.fields import (
1✔
23
    BallotInputWidget,
24
    StrippedCharField,
25
    ValidBallotField,
26
)
27
from people.helpers import clean_biography, clean_birth_date
1✔
28
from popolo.models import Membership
1✔
29
from search.utils import search_person_by_name
1✔
30

31

32
class BaseBulkAddFormSet(forms.BaseFormSet):
1✔
33
    renderer = None
1✔
34

35
    def __init__(self, *args, **kwargs):
1✔
36
        if "source" in kwargs:
1✔
37
            self.source = kwargs["source"]
1✔
38
            del kwargs["source"]
1✔
39

40
        if "ballot" in kwargs:
1✔
41
            self.ballot = kwargs["ballot"]
1✔
42
            del kwargs["ballot"]
1✔
43

44
        super().__init__(*args, **kwargs)
1✔
45

46
    def add_fields(self, form, index):
1✔
47
        super().add_fields(form, index)
1✔
48
        form.initial["ballot"] = self.ballot.ballot_paper_id
1✔
49

50
        if hasattr(self, "source"):
1✔
51
            form.fields["source"].initial = self.source
1✔
52
            form.fields["source"].widget = forms.HiddenInput()
1✔
53

54
    def clean(self):
1✔
55
        if (
1✔
56
            not self.initial_form_count()
57
            and self.ballot.membership_set.exists()
58
        ):
59
            # No extra forms exist, meaning no new people were added
60
            return super().clean()
1✔
61

62
        # Check if any forms have data
63
        has_data = any(
1✔
64
            form.is_valid()
65
            and form.cleaned_data
66
            and not form.cleaned_data.get("DELETE", False)
67
            for form in self.forms
68
        )
69

70
        if not has_data and not self.ballot.membership_set.exists():
1✔
71
            raise ValidationError("At least one person required on this ballot")
1✔
72

73
        return super().clean()
1✔
74

75

76
class BulkAddFormSet(BaseBulkAddFormSet):
1✔
77
    def __init__(self, *args, **kwargs):
1✔
78
        super().__init__(*args, **kwargs)
1✔
79
        self.parties = Party.objects.register(
1✔
80
            self.ballot.post.party_set.slug.upper()
81
        ).default_party_choices(extra_party_ids=self.initial_party_ids)
82
        self.previous_party_affiliations_choices = (
1✔
83
            self.get_previous_party_affiliations_choices()
84
        )
85

86
    def total_form_count(self) -> int:
1✔
87
        """
88
        Base the additional fields on the seats contested multiplied
89
        by a sensible default.
90

91
        This is to prevent adding loads of additional fields if not needed.
92
        """
93

94
        seats_contested = self.ballot.winner_count
1✔
95
        # 3.5 is the average number of candidates per seat, historically, but
96
        # let's round that up to 4. Then multiply by the seats contested for
97
        # this ballot
98
        return int(seats_contested * 4)
1✔
99

100
    def get_form_kwargs(self, index):
1✔
101
        kwargs = super().get_form_kwargs(index)
1✔
102
        kwargs["party_choices"] = self.parties
1✔
103
        kwargs[
1✔
104
            "previous_party_affiliations_choices"
105
        ] = self.previous_party_affiliations_choices
106
        return kwargs
1✔
107

108
    @property
1✔
109
    def initial_party_ids(self):
1✔
110
        """
111
        Returns a list of any party ID's that are included in initial data
112
        """
113
        if self.initial is None:
1✔
114
            return []
1✔
115
        return [d.get("party")[0].split("__")[0] for d in self.initial]
1✔
116

117
    def get_previous_party_affiliations_choices(self):
1✔
118
        """
119
        Return choices for previous_party_affilations field. By getting these on
120
        the formset and passing to the form, it saves the query for every
121
        individual form
122
        """
123
        if not self.ballot.is_welsh_run:
1✔
124
            return []
1✔
125

126
        parties = Party.objects.register("GB").active_in_last_year(
1✔
127
            date=self.ballot.election.election_date
128
        )
129
        return parties.values_list("ec_id", "name")
1✔
130

131

132
class BaseBulkAddReviewFormSet(BaseBulkAddFormSet):
1✔
133
    def suggested_people(
1✔
134
        self,
135
        person_name,
136
        new_party,
137
        new_election,
138
        new_name,
139
        ballot=None,
140
    ):
141
        """
142
        At a basic level, use the person search system to find existing people
143
        who might be the same as the values submitted via bulk adding.
144

145
        Normal weightings apply, with two additional weights:
146

147
        1. If there's a match for a person that's already listed on the ballot
148
           then that gets pulled to the top of the list.
149
        2. People with the same party as the new person are weighted higher.
150
           Party matching like this isn't going to be right in all cases
151
           (people switch party) but it will be more useful than name alone
152
           more of the time
153

154
        These two additional weights combined should catch the 'this candidate
155
        is already listed on the ballot' case, or at least it's a simpler
156
        UX than simply asking users to de-duplicate existing people on the
157
        previous form.
158
        """
159
        if not person_name:
1✔
NEW
160
            return None
×
161

162
        org_id = (
1✔
163
            new_election.organization.pk
164
            if new_election and new_election.organization
165
            else None
166
        )
167
        annotations = {
1✔
168
            "new_party": Value(new_party, output_field=CharField()),
169
            "new_organisation": Value(
170
                org_id,
171
                output_field=IntegerField(),
172
            ),
173
            "new_name": Value(new_name, output_field=CharField()),
174
        }
175

176
        qs = (
1✔
177
            search_person_by_name(person_name, synonym=True)
178
            .prefetch_related(
179
                Prefetch(
180
                    "memberships",
181
                    queryset=Membership.objects.select_related(
182
                        "party",
183
                        "ballot",
184
                        "ballot__election",
185
                        "ballot__election__organization",
186
                        "ballot__post",
187
                    ).order_by("-ballot__election__election_date"),
188
                ),
189
            )
190
            .annotate(**annotations)
191
        )
192

193
        order_by = []
1✔
194

195
        if ballot:
1✔
196
            # Annotate the QS with on_ballot: True if the matched person
197
            # exists on a given ballot.
198
            on_ballot = Exists(
1✔
199
                Membership.objects.filter(person=OuterRef("pk"), ballot=ballot)
200
            )
201
            qs = qs.annotate(on_ballot=on_ballot)
1✔
202
            order_by.append("-on_ballot")
1✔
203

204
        if new_party:
1✔
205
            # Annotate the QS with same_party: True if the person has a
206
            # candidacy with the same party as the given one
207
            same_party = Exists(
1✔
208
                Membership.objects.filter(
209
                    person=OuterRef("pk"), party__ec_id=new_party
210
                )
211
            )
212
            qs = qs.annotate(same_party=same_party)
1✔
213
            order_by.append("-same_party")
1✔
214

215
        if order_by:
1✔
216
            qs = qs.order_by(*order_by, "-rank", "membership_count")
1✔
217

218
        return qs[:5]
1✔
219

220
    def add_fields(self, form, index):
1✔
221
        super().add_fields(form, index)
1✔
222
        if not form["name"].value():
1✔
223
            return
1✔
224
        suggestions = self.suggested_people(
1✔
225
            form["name"].value(),
226
            new_party=form.initial.get("party"),
227
            new_election=self.ballot.election,
228
            new_name=form.initial.get("name"),
229
            ballot=self.ballot,
230
        )
231

232
        form.fields["select_person"] = PersonSuggestionField(
1✔
233
            suggestions=suggestions,
234
            new_name=form.initial.get("name"),
235
            widget=PersonSuggestionRadioSelect,
236
        )
237

238
        form.fields["party"] = forms.CharField(
1✔
239
            widget=forms.HiddenInput(
240
                attrs={"readonly": "readonly", "class": "party-select"}
241
            ),
242
            required=False,
243
        )
244
        form.fields["sopn_last_name"] = StrippedCharField(
1✔
245
            widget=forms.HiddenInput(attrs={"readonly": "readonly"}),
246
            required=False,
247
        )
248
        form.fields["sopn_first_names"] = StrippedCharField(
1✔
249
            widget=forms.HiddenInput(attrs={"readonly": "readonly"}),
250
            required=False,
251
        )
252

253
    def clean(self):
1✔
254
        errors = []
1✔
255
        if not hasattr(self, "ballot"):
1✔
256
            return super().clean()
×
257

258
        if self.ballot.candidates_locked:
1✔
259
            raise ValidationError(
1✔
260
                "Candidates have already been locked for this ballot"
261
            )
262
        for form in self.forms:
1✔
263
            if not form.is_valid():
1✔
264
                continue
×
265
            form_data = form.cleaned_data
1✔
266
            if (
1✔
267
                "select_person" in form_data
268
                and form_data["select_person"] == "_new"
269
            ):
270
                continue
1✔
271
            qs = (
1✔
272
                Membership.objects.filter(ballot__election=self.ballot.election)
273
                .filter(person_id=form_data.get("select_person"))
274
                .exclude(ballot=self.ballot)
275
                .exclude(ballot__candidates_locked=True)
276
            )
277
            if qs.exists():
1✔
278
                errors.append(
1✔
279
                    forms.ValidationError(
280
                        "'{}' is marked as standing in another ballot for this "
281
                        "election. Check you're entering the correct "
282
                        "information for {}".format(
283
                            form_data["name"], self.ballot.post.label
284
                        )
285
                    )
286
                )
287
        if errors:
1✔
288
            raise forms.ValidationError(errors)
1✔
289
        return super().clean()
1✔
290

291

292
class NameOnlyPersonForm(forms.Form):
1✔
293
    name = StrippedCharField(
1✔
294
        label="Name (style: Ali McKay Smith not SMITH Ali McKay)",
295
        required=True,
296
        widget=forms.TextInput(
297
            attrs={"class": "person_name", "spellcheck": "false"}
298
        ),
299
    )
300
    ballot = ValidBallotField(
1✔
301
        widget=BallotInputWidget(attrs={"type": "hidden"})
302
    )
303
    sopn_last_name = StrippedCharField(
1✔
304
        widget=forms.HiddenInput(attrs={"readonly": "readonly"}),
305
        required=False,
306
    )
307
    sopn_first_names = StrippedCharField(
1✔
308
        widget=forms.HiddenInput(attrs={"readonly": "readonly"}),
309
        required=False,
310
    )
311

312

313
class BulkAddByPartyForm(NameOnlyPersonForm):
1✔
314
    biography = StrippedCharField(
1✔
315
        label="Statement to Voters",
316
        required=False,
317
        widget=forms.Textarea(
318
            attrs={"class": "person_biography"},
319
        ),
320
    )
321
    gender = StrippedCharField(
1✔
322
        label="Gender (e.g. “male”, “female”)", required=False
323
    )
324
    birth_date = forms.CharField(
1✔
325
        label="Year of birth (a four digit year)",
326
        required=False,
327
        widget=forms.NumberInput,
328
    )
329
    person_identifiers = PersonIdentifierFieldSet(
1✔
330
        label="Links and social media",
331
        required=False,
332
    )
333

334
    def clean_biography(self):
1✔
335
        bio = self.cleaned_data["biography"]
1✔
336
        return clean_biography(bio)
1✔
337

338
    def clean_birth_date(self):
1✔
339
        bd = self.cleaned_data["birth_date"]
1✔
340
        return clean_birth_date(bd)
1✔
341

342

343
class QuickAddSinglePersonForm(PopulatePartiesMixin, NameOnlyPersonForm):
1✔
344
    source = forms.CharField(required=True)
1✔
345
    party = PartyIdentifierField()
1✔
346
    previous_party_affiliations = PreviousPartyAffiliationsField()
1✔
347

348
    def __init__(self, **kwargs):
1✔
349
        previous_party_affiliations_choices = kwargs.pop(
1✔
350
            "previous_party_affiliations_choices", []
351
        )
352
        super().__init__(**kwargs)
1✔
353
        self.fields[
1✔
354
            "previous_party_affiliations"
355
        ].choices = previous_party_affiliations_choices
356

357
    def has_changed(self, *args, **kwargs):
1✔
358
        if "name" not in self.changed_data:
1✔
359
            return False
1✔
360
        return super().has_changed(*args, **kwargs)
1✔
361

362
    def clean(self):
1✔
363
        if (
1✔
364
            not self.cleaned_data["ballot"].is_welsh_run
365
            and self.cleaned_data["previous_party_affiliations"]
366
        ):
367
            raise ValidationError(
×
368
                "Previous party affiliations are invalid for this ballot"
369
            )
370
        return super().clean()
1✔
371

372

373
class ReviewSinglePersonNameOnlyForm(forms.Form):
1✔
374
    def __init__(self, *args, **kwargs):
1✔
375
        kwargs.pop("party_choices", None)
1✔
376
        super().__init__(*args, **kwargs)
1✔
377

378
    name = StrippedCharField(
1✔
379
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
380
    )
381

382

383
class ReviewBulkAddByPartyForm(ReviewSinglePersonNameOnlyForm):
1✔
384
    biography = StrippedCharField(
1✔
385
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
386
    )
387
    gender = StrippedCharField(
1✔
388
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
389
    )
390
    birth_date = forms.CharField(
1✔
391
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
392
    )
393
    person_identifiers = forms.CharField(
1✔
394
        required=False,
395
        widget=forms.HiddenInput(
396
            attrs={"readonly": "readonly"},
397
        ),
398
    )
399

400

401
class ReviewSinglePersonForm(ReviewSinglePersonNameOnlyForm):
1✔
402
    source = forms.CharField(
1✔
403
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
404
    )
405
    party_description = forms.ModelChoiceField(
1✔
406
        required=False,
407
        widget=forms.HiddenInput(),
408
        queryset=PartyDescription.objects.all(),
409
    )
410
    party_description_text = forms.CharField(
1✔
411
        required=False, widget=forms.HiddenInput()
412
    )
413
    previous_party_affiliations = forms.CharField(
1✔
414
        required=False, widget=forms.HiddenInput()
415
    )
416

417

418
BulkAddFormSetFactory = forms.formset_factory(
1✔
419
    QuickAddSinglePersonForm, extra=0, formset=BulkAddFormSet, can_delete=True
420
)
421

422

423
BulkAddReviewNameOnlyFormSet = forms.formset_factory(
1✔
424
    ReviewSinglePersonNameOnlyForm, extra=0, formset=BaseBulkAddReviewFormSet
425
)
426

427
BulkAddReviewFormSet = forms.formset_factory(
1✔
428
    ReviewSinglePersonForm, extra=0, formset=BaseBulkAddReviewFormSet
429
)
430

431

432
class BaseBulkAddByPartyFormset(forms.BaseFormSet):
1✔
433
    renderer = None
1✔
434

435
    def __init__(self, *args, **kwargs):
1✔
436
        self.ballot = kwargs["ballot"]
1✔
437
        kwargs["prefix"] = self.ballot.pk
1✔
438
        self.extra = self.ballot.winner_count
1✔
439
        del kwargs["ballot"]
1✔
440

441
        super().__init__(*args, **kwargs)
1✔
442

443
    def add_fields(self, form, index):
1✔
444
        super().add_fields(form, index)
1✔
445
        if self.ballot.election.party_lists_in_use:
1✔
446
            form.fields["party_list_position"] = forms.IntegerField(
×
447
                label="Position in party list ('1' for first, '2' for second, etc.)",
448
                min_value=1,
449
                required=False,
450
                initial=index + 1,
451
                widget=forms.NumberInput(attrs={"class": "party-position"}),
452
            )
453

454
        if self.ballot.candidates_locked:
1✔
455
            for name, field in form.fields.items():
×
456
                field.disabled = True
×
457
                form.fields[name] = field
×
458

459

460
BulkAddByPartyFormset = forms.formset_factory(
1✔
461
    BulkAddByPartyForm,
462
    extra=6,
463
    formset=BaseBulkAddByPartyFormset,
464
    can_delete=False,
465
)
466

467

468
class PartyBulkAddReviewFormSet(BaseBulkAddReviewFormSet):
1✔
469
    def __init__(self, *args, **kwargs):
1✔
470
        self.ballot = kwargs["ballot"]
1✔
471
        kwargs["prefix"] = self.ballot.pk
1✔
472
        self.extra = self.ballot.winner_count
1✔
473
        del kwargs["ballot"]
1✔
474

475
        super().__init__(*args, **kwargs)
1✔
476

477
    def clean(self):
1✔
478
        """
479
        Use this method to prevent users from adding candidates to ballots
480
        that can't have candidates added to them, like locked or cancelled
481
        ballots
482
        """
483
        if self.ballot.candidates_locked:
1✔
484
            raise ValidationError("Cannot add candidates to a locked ballot")
×
485
        if self.ballot.cancelled:
1✔
486
            raise ValidationError(
×
487
                "Cannot add candidates to a cancelled election"
488
            )
489

490
    def add_fields(self, form, index):
1✔
491
        super().add_fields(form, index)
1✔
492
        if self.ballot.election.party_lists_in_use:
1✔
493
            form.fields["party_list_position"] = forms.IntegerField(
×
494
                min_value=1, required=False, widget=forms.HiddenInput()
495
            )
496

497

498
PartyBulkAddReviewNameOnlyFormSet = forms.formset_factory(
1✔
499
    ReviewBulkAddByPartyForm, extra=0, formset=PartyBulkAddReviewFormSet
500
)
501

502

503
class SelectPartyForm(forms.Form):
1✔
504
    def __init__(self, *args, **kwargs):
1✔
505
        election = kwargs.pop("election")
1✔
506
        super().__init__(*args, **kwargs)
1✔
507

508
        self.election = election
1✔
509
        party_set_qs = (
1✔
510
            election.ballot_set.all()
511
            .order_by("post__party_set")
512
            .values_list("post__party_set__slug", flat=True)
513
        )
514

515
        registers = {p.upper() for p in party_set_qs}
1✔
516
        for register in registers:
1✔
517
            choices = Party.objects.register(register).party_choices(
1✔
518
                include_description_ids=True
519
            )
520
            field = PartyIdentifierField(
1✔
521
                label=f"{register} parties", choices=choices
522
            )
523

524
            field.fields[0].choices = choices
1✔
525
            field.widget.attrs["data-party-register"] = register
1✔
526
            field.widget.attrs["register"] = register
1✔
527
            self.fields[f"party_{register}"] = field
1✔
528

529
    def clean(self):
1✔
530
        form_data = self.cleaned_data
1✔
531
        if len([v for v in form_data.values() if v]) != 1:
1✔
532
            raise forms.ValidationError("Select one and only one party")
1✔
533

534
        form_data["party"] = [v for v in form_data.values() if v][0]
1✔
535
        return form_data
1✔
536

537

538
class AddByPartyForm(forms.Form):
1✔
539
    source = forms.CharField(required=True)
1✔
540

541

542
class DeleteRawPeopleForm(forms.Form):
1✔
543
    ballot_paper_id = forms.CharField(required=True, widget=forms.HiddenInput)
1✔
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

© 2026 Coveralls, Inc