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

DemocracyClub / yournextrepresentative / 354f6bd3-acc2-44c9-8298-67e1291fc3d4

26 Mar 2026 02:08PM UTC coverage: 74.635% (+0.08%) from 74.553%
354f6bd3-acc2-44c9-8298-67e1291fc3d4

push

circleci

web-flow
Merge pull request #2681 from DemocracyClub/remove-existing-from-bulk-add-flow

Remove existing candidates from bulk add flow

873 of 1226 branches covered (71.21%)

Branch coverage included in aggregate %.

167 of 173 new or added lines in 6 files covered. (96.53%)

8 existing lines in 1 file now uncovered.

7875 of 10495 relevant lines covered (75.04%)

0.75 hits per line

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

94.86
/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
        form_counts = []
1✔
95

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

102
        if self.is_bound:
1✔
103
            form_counts.append(super().total_form_count())
1✔
104

105
        if hasattr(self.ballot, "raw_people"):
1✔
NEW
106
            form_counts.append(len(self.ballot.rawpeople.textract_data))
×
107

108
        form_counts.append(self.ballot.membership_count)
1✔
109
        return max(form_counts) + 1
1✔
110

111
    def get_form_kwargs(self, index):
1✔
112
        kwargs = super().get_form_kwargs(index)
1✔
113
        kwargs["party_choices"] = self.parties
1✔
114
        kwargs[
1✔
115
            "previous_party_affiliations_choices"
116
        ] = self.previous_party_affiliations_choices
117
        return kwargs
1✔
118

119
    @property
1✔
120
    def initial_party_ids(self):
1✔
121
        """
122
        Returns a list of any party ID's that are included in initial data
123
        """
124
        if self.initial is None:
1✔
125
            return []
1✔
126
        return [d.get("party")[0].split("__")[0] for d in self.initial]
1✔
127

128
    def get_previous_party_affiliations_choices(self):
1✔
129
        """
130
        Return choices for previous_party_affilations field. By getting these on
131
        the formset and passing to the form, it saves the query for every
132
        individual form
133
        """
134
        if not self.ballot.is_welsh_run:
1✔
135
            return []
1✔
136

137
        parties = Party.objects.register("GB").active_in_last_year(
1✔
138
            date=self.ballot.election.election_date
139
        )
140
        return parties.values_list("ec_id", "name")
1✔
141

142

143
class BaseBulkAddReconcileFormSet(BaseBulkAddFormSet):
1✔
144
    def suggested_people(
1✔
145
        self,
146
        person_name,
147
        new_party,
148
        new_election,
149
        new_name,
150
        ballot=None,
151
    ):
152
        """
153
        At a basic level, use the person search system to find existing people
154
        who might be the same as the values submitted via bulk adding.
155

156
        Normal weightings apply, with two additional weights:
157

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

165
        These two additional weights combined should catch the 'this candidate
166
        is already listed on the ballot' case, or at least it's a simpler
167
        UX than simply asking users to de-duplicate existing people on the
168
        previous form.
169
        """
170
        if not person_name:
1✔
NEW
171
            return None
×
172

173
        org_id = (
1✔
174
            new_election.organization.pk
175
            if new_election and new_election.organization
176
            else None
177
        )
178
        annotations = {
1✔
179
            "new_party": Value(new_party, output_field=CharField()),
180
            "new_organisation": Value(
181
                org_id,
182
                output_field=IntegerField(),
183
            ),
184
            "new_name": Value(new_name, output_field=CharField()),
185
        }
186

187
        qs = (
1✔
188
            search_person_by_name(person_name, synonym=True)
189
            .prefetch_related(
190
                Prefetch(
191
                    "memberships",
192
                    queryset=Membership.objects.select_related(
193
                        "party",
194
                        "ballot",
195
                        "ballot__election",
196
                        "ballot__election__organization",
197
                        "ballot__post",
198
                        "ballot__sopn",
199
                    ).order_by("-ballot__election__election_date"),
200
                ),
201
                "other_names",
202
            )
203
            .annotate(**annotations)
204
        )
205

206
        order_by = []
1✔
207

208
        if ballot:
1✔
209
            # Annotate the QS with on_ballot: True if the matched person
210
            # exists on a given ballot.
211
            on_ballot = Exists(
1✔
212
                Membership.objects.filter(person=OuterRef("pk"), ballot=ballot)
213
            )
214
            qs = qs.annotate(on_ballot=on_ballot)
1✔
215
            order_by.append("-on_ballot")
1✔
216

217
        if new_party:
1✔
218
            # Annotate the QS with same_party: True if the person has a
219
            # candidacy with the same party as the given one
220
            same_party = Exists(
1✔
221
                Membership.objects.filter(
222
                    person=OuterRef("pk"), party__ec_id=new_party
223
                )
224
            )
225
            qs = qs.annotate(same_party=same_party)
1✔
226
            order_by.append("-same_party")
1✔
227

228
        if order_by:
1✔
229
            qs = qs.order_by(*order_by, "-rank", "membership_count")
1✔
230

231
        return qs[:5]
1✔
232

233
    def add_fields(self, form, index):
1✔
234
        super().add_fields(form, index)
1✔
235
        if not form["name"].value():
1✔
236
            return
1✔
237
        # On POST, form.initial is empty; fall back to the submitted POST data
238
        party_id = form.initial.get("party_id") or form.data.get(
1✔
239
            form.add_prefix("party_id")
240
        )
241
        suggestions = self.suggested_people(
1✔
242
            form["name"].value(),
243
            new_party=party_id,
244
            new_election=self.ballot.election,
245
            new_name=form.initial.get("name"),
246
            ballot=self.ballot,
247
        )
248

249
        form.fields["select_person"] = PersonSuggestionField(
1✔
250
            suggestions=suggestions,
251
            new_name=form.initial.get("name"),
252
            widget=PersonSuggestionRadioSelect,
253
        )
254

255
        # If reconciled data exists, use that as the initial values
256
        previous_selection = form.initial.get("select_person")
1✔
257
        if previous_selection and form.fields["select_person"].valid_value(
1✔
258
            str(previous_selection)
259
        ):
260
            form.fields["select_person"].initial = str(previous_selection)
1✔
261

262
        form.fields["party_id"] = forms.CharField(
1✔
263
            widget=forms.HiddenInput(
264
                attrs={"readonly": "readonly", "class": "party-select"}
265
            ),
266
            required=False,
267
        )
268
        form.fields["sopn_last_name"] = StrippedCharField(
1✔
269
            widget=forms.HiddenInput(attrs={"readonly": "readonly"}),
270
            required=False,
271
        )
272
        form.fields["sopn_first_names"] = StrippedCharField(
1✔
273
            widget=forms.HiddenInput(attrs={"readonly": "readonly"}),
274
            required=False,
275
        )
276

277
    def clean(self):
1✔
278
        errors = []
1✔
279
        if not hasattr(self, "ballot"):
1✔
280
            return super().clean()
×
281

282
        if self.ballot.candidates_locked:
1✔
283
            raise ValidationError(
1✔
284
                "Candidates have already been locked for this ballot"
285
            )
286
        for form in self.forms:
1✔
287
            if not form.is_valid():
1✔
288
                continue
×
289
            form_data = form.cleaned_data
1✔
290
            if (
1✔
291
                "select_person" in form_data
292
                and form_data["select_person"] == "_new"
293
            ):
294
                continue
1✔
295
            qs = (
1✔
296
                Membership.objects.filter(ballot__election=self.ballot.election)
297
                .filter(person_id=form_data.get("select_person"))
298
                .exclude(ballot=self.ballot)
299
                .exclude(ballot__candidates_locked=True)
300
            )
301
            if qs.exists():
1✔
302
                errors.append(
1✔
303
                    forms.ValidationError(
304
                        "'{}' is marked as standing in another ballot for this "
305
                        "election. Check you're entering the correct "
306
                        "information for {}".format(
307
                            form_data["name"], self.ballot.post.label
308
                        )
309
                    )
310
                )
311
        if errors:
1✔
312
            raise forms.ValidationError(errors)
1✔
313
        return super().clean()
1✔
314

315

316
class NameOnlyPersonForm(forms.Form):
1✔
317
    name = StrippedCharField(
1✔
318
        label="Name (style: Ali McKay Smith not SMITH Ali McKay)",
319
        required=True,
320
        widget=forms.TextInput(
321
            attrs={"class": "person_name", "spellcheck": "false"}
322
        ),
323
    )
324
    ballot = ValidBallotField(
1✔
325
        widget=BallotInputWidget(attrs={"type": "hidden"})
326
    )
327
    sopn_last_name = StrippedCharField(
1✔
328
        widget=forms.HiddenInput(attrs={"readonly": "readonly"}),
329
        required=False,
330
    )
331
    sopn_first_names = StrippedCharField(
1✔
332
        widget=forms.HiddenInput(attrs={"readonly": "readonly"}),
333
        required=False,
334
    )
335

336

337
class BulkAddByPartyForm(NameOnlyPersonForm):
1✔
338
    biography = StrippedCharField(
1✔
339
        label="Statement to Voters",
340
        required=False,
341
        widget=forms.Textarea(
342
            attrs={"class": "person_biography"},
343
        ),
344
    )
345
    gender = StrippedCharField(
1✔
346
        label="Gender (e.g. “male”, “female”)", required=False
347
    )
348
    birth_date = forms.CharField(
1✔
349
        label="Year of birth (a four digit year)",
350
        required=False,
351
        widget=forms.NumberInput,
352
    )
353
    person_identifiers = PersonIdentifierFieldSet(
1✔
354
        label="Links and social media",
355
        required=False,
356
    )
357

358
    def clean_biography(self):
1✔
359
        bio = self.cleaned_data["biography"]
1✔
360
        return clean_biography(bio)
1✔
361

362
    def clean_birth_date(self):
1✔
363
        bd = self.cleaned_data["birth_date"]
1✔
364
        return clean_birth_date(bd)
1✔
365

366

367
class QuickAddSinglePersonForm(PopulatePartiesMixin, NameOnlyPersonForm):
1✔
368
    source = forms.CharField(required=True)
1✔
369
    party = PartyIdentifierField()
1✔
370
    previous_party_affiliations = PreviousPartyAffiliationsField()
1✔
371

372
    def __init__(self, **kwargs):
1✔
373
        previous_party_affiliations_choices = kwargs.pop(
1✔
374
            "previous_party_affiliations_choices", []
375
        )
376
        super().__init__(**kwargs)
1✔
377
        self.fields[
1✔
378
            "previous_party_affiliations"
379
        ].choices = previous_party_affiliations_choices
380

381
    def has_changed(self, *args, **kwargs):
1✔
382
        if "name" not in self.changed_data:
1✔
383
            return False
1✔
384
        return super().has_changed(*args, **kwargs)
1✔
385

386
    def clean(self):
1✔
387
        if (
1✔
388
            not self.cleaned_data["ballot"].is_welsh_run
389
            and self.cleaned_data["previous_party_affiliations"]
390
        ):
391
            raise ValidationError(
×
392
                "Previous party affiliations are invalid for this ballot"
393
            )
394
        return super().clean()
1✔
395

396

397
class ReconcileSinglePersonNameOnlyForm(forms.Form):
1✔
398
    def __init__(self, *args, **kwargs):
1✔
399
        kwargs.pop("party_choices", None)
1✔
400
        super().__init__(*args, **kwargs)
1✔
401

402
    name = StrippedCharField(
1✔
403
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
404
    )
405

406

407
class ReviewBulkAddByPartyForm(ReconcileSinglePersonNameOnlyForm):
1✔
408
    biography = StrippedCharField(
1✔
409
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
410
    )
411
    gender = StrippedCharField(
1✔
412
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
413
    )
414
    birth_date = forms.CharField(
1✔
415
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
416
    )
417
    person_identifiers = forms.CharField(
1✔
418
        required=False,
419
        widget=forms.HiddenInput(
420
            attrs={"readonly": "readonly"},
421
        ),
422
    )
423

424

425
class ReconcileSinglePersonForm(ReconcileSinglePersonNameOnlyForm):
1✔
426
    source = forms.CharField(
1✔
427
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
428
    )
429
    description_id = forms.ModelChoiceField(
1✔
430
        required=False,
431
        widget=forms.HiddenInput(),
432
        queryset=PartyDescription.objects.all(),
433
    )
434
    party_description_text = forms.CharField(
1✔
435
        required=False, widget=forms.HiddenInput()
436
    )
437
    previous_party_affiliations = forms.CharField(
1✔
438
        required=False, widget=forms.HiddenInput()
439
    )
440

441

442
BulkAddFormSetFactory = forms.formset_factory(
1✔
443
    QuickAddSinglePersonForm, extra=0, formset=BulkAddFormSet, can_delete=True
444
)
445

446

447
BulkAddReconcileNameOnlyFormSet = forms.formset_factory(
1✔
448
    ReconcileSinglePersonNameOnlyForm,
449
    extra=0,
450
    formset=BaseBulkAddReconcileFormSet,
451
)
452

453
BulkAddReconcileFormSet = forms.formset_factory(
1✔
454
    ReconcileSinglePersonForm, extra=0, formset=BaseBulkAddReconcileFormSet
455
)
456

457

458
class BaseBulkAddByPartyFormset(forms.BaseFormSet):
1✔
459
    renderer = None
1✔
460

461
    def __init__(self, *args, **kwargs):
1✔
462
        self.ballot = kwargs["ballot"]
1✔
463
        kwargs["prefix"] = self.ballot.pk
1✔
464
        self.extra = self.ballot.winner_count
1✔
465
        del kwargs["ballot"]
1✔
466

467
        super().__init__(*args, **kwargs)
1✔
468

469
    def add_fields(self, form, index):
1✔
470
        super().add_fields(form, index)
1✔
471
        if self.ballot.election.party_lists_in_use:
1✔
472
            form.fields["party_list_position"] = forms.IntegerField(
×
473
                label="Position in party list ('1' for first, '2' for second, etc.)",
474
                min_value=1,
475
                required=False,
476
                initial=index + 1,
477
                widget=forms.NumberInput(attrs={"class": "party-position"}),
478
            )
479

480
        if self.ballot.candidates_locked:
1✔
481
            for name, field in form.fields.items():
×
482
                field.disabled = True
×
483
                form.fields[name] = field
×
484

485

486
BulkAddByPartyFormset = forms.formset_factory(
1✔
487
    BulkAddByPartyForm,
488
    extra=6,
489
    formset=BaseBulkAddByPartyFormset,
490
    can_delete=False,
491
)
492

493

494
class PartyBulkAddReviewFormSet(BaseBulkAddReconcileFormSet):
1✔
495
    def __init__(self, *args, **kwargs):
1✔
496
        self.ballot = kwargs["ballot"]
1✔
497
        kwargs["prefix"] = self.ballot.pk
1✔
498
        self.extra = self.ballot.winner_count
1✔
499
        del kwargs["ballot"]
1✔
500

501
        super().__init__(*args, **kwargs)
1✔
502

503
    def clean(self):
1✔
504
        """
505
        Use this method to prevent users from adding candidates to ballots
506
        that can't have candidates added to them, like locked or cancelled
507
        ballots
508
        """
509
        if self.ballot.candidates_locked:
1✔
510
            raise ValidationError("Cannot add candidates to a locked ballot")
×
511
        if self.ballot.cancelled:
1✔
512
            raise ValidationError(
×
513
                "Cannot add candidates to a cancelled election"
514
            )
515

516
    def add_fields(self, form, index):
1✔
517
        super().add_fields(form, index)
1✔
518
        if self.ballot.election.party_lists_in_use:
1✔
519
            form.fields["party_list_position"] = forms.IntegerField(
×
520
                min_value=1, required=False, widget=forms.HiddenInput()
521
            )
522

523

524
PartyBulkAddReviewNameOnlyFormSet = forms.formset_factory(
1✔
525
    ReviewBulkAddByPartyForm, extra=0, formset=PartyBulkAddReviewFormSet
526
)
527

528

529
class SelectPartyForm(forms.Form):
1✔
530
    def __init__(self, *args, **kwargs):
1✔
531
        election = kwargs.pop("election")
1✔
532
        super().__init__(*args, **kwargs)
1✔
533

534
        self.election = election
1✔
535
        party_set_qs = (
1✔
536
            election.ballot_set.all()
537
            .order_by("post__party_set")
538
            .values_list("post__party_set__slug", flat=True)
539
        )
540

541
        registers = {p.upper() for p in party_set_qs}
1✔
542
        for register in registers:
1✔
543
            choices = Party.objects.register(register).party_choices(
1✔
544
                include_description_ids=True
545
            )
546
            field = PartyIdentifierField(
1✔
547
                label=f"{register} parties", choices=choices
548
            )
549

550
            field.fields[0].choices = choices
1✔
551
            field.widget.attrs["data-party-register"] = register
1✔
552
            field.widget.attrs["register"] = register
1✔
553
            self.fields[f"party_{register}"] = field
1✔
554

555
    def clean(self):
1✔
556
        form_data = self.cleaned_data
1✔
557
        if len([v for v in form_data.values() if v]) != 1:
1✔
558
            raise forms.ValidationError("Select one and only one party")
1✔
559

560
        form_data["party"] = [v for v in form_data.values() if v][0]
1✔
561
        return form_data
1✔
562

563

564
class AddByPartyForm(forms.Form):
1✔
565
    source = forms.CharField(required=True)
1✔
566

567

568
class DeleteRawPeopleForm(forms.Form):
1✔
569
    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