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

DemocracyClub / yournextrepresentative / 5a0fe47e-a695-45e2-9f0f-c231c2557205

06 Mar 2026 08:26AM UTC coverage: 74.464% (-0.03%) from 74.496%
5a0fe47e-a695-45e2-9f0f-c231c2557205

Pull #2681

circleci

symroe
Dynamically add extra forms based on seats contested

We will need to use JS to allow adding more forms if needed, but this
change implements sensible defaults that will be better UX than always
adding 15.
Pull Request #2681: Remove existing from bulk add flow

865 of 1218 branches covered (71.02%)

Branch coverage included in aggregate %.

53 of 58 new or added lines in 2 files covered. (91.38%)

2 existing lines in 1 file now uncovered.

7848 of 10483 relevant lines covered (74.86%)

0.75 hits per line

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

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

25

26
class BaseBulkAddFormSet(forms.BaseFormSet):
1✔
27
    renderer = None
1✔
28

29
    def __init__(self, *args, **kwargs):
1✔
30
        if "source" in kwargs:
1✔
31
            self.source = kwargs["source"]
1✔
32
            del kwargs["source"]
1✔
33

34
        if "ballot" in kwargs:
1✔
35
            self.ballot = kwargs["ballot"]
1✔
36
            del kwargs["ballot"]
1✔
37

38
        super().__init__(*args, **kwargs)
1✔
39

40
    def add_fields(self, form, index):
1✔
41
        super().add_fields(form, index)
1✔
42
        form.initial["ballot"] = self.ballot.ballot_paper_id
1✔
43

44
        if hasattr(self, "source"):
1✔
45
            form.fields["source"].initial = self.source
1✔
46
            form.fields["source"].widget = forms.HiddenInput()
1✔
47

48
    def clean(self):
1✔
49
        if (
1✔
50
            not self.initial_form_count()
51
            and self.ballot.membership_set.exists()
52
        ):
53
            # No extra forms exist, meaning no new people were added
54
            return super().clean()
1✔
55

56
        # Check if any forms have data
57
        has_data = any(
1✔
58
            form.is_valid()
59
            and form.cleaned_data
60
            and not form.cleaned_data.get("DELETE", False)
61
            for form in self.forms
62
        )
63

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

67
        return super().clean()
1✔
68

69

70
class BulkAddFormSet(BaseBulkAddFormSet):
1✔
71
    def __init__(self, *args, **kwargs):
1✔
72
        super().__init__(*args, **kwargs)
1✔
73
        self.parties = Party.objects.register(
1✔
74
            self.ballot.post.party_set.slug.upper()
75
        ).default_party_choices(extra_party_ids=self.initial_party_ids)
76
        self.previous_party_affiliations_choices = (
1✔
77
            self.get_previous_party_affiliations_choices()
78
        )
79

80
    def total_form_count(self) -> int:
1✔
81
        """
82
        Base the additional fields on the seats contested multiplied
83
        by a sensible default.
84

85
        This is to prevent adding loads of additional fields if not needed.
86
        """
87

88
        seats_contested = self.ballot.winner_count
1✔
89
        # 3.5 is the average number of candidates per seat, historically, but
90
        # let's round that up to 4. Then multiply by the seats contested for
91
        # this ballot
92
        return int(seats_contested * 4)
1✔
93

94
    def get_form_kwargs(self, index):
1✔
95
        kwargs = super().get_form_kwargs(index)
1✔
96
        kwargs["party_choices"] = self.parties
1✔
97
        kwargs[
1✔
98
            "previous_party_affiliations_choices"
99
        ] = self.previous_party_affiliations_choices
100
        return kwargs
1✔
101

102
    @property
1✔
103
    def initial_party_ids(self):
1✔
104
        """
105
        Returns a list of any party ID's that are included in initial data
106
        """
107
        if self.initial is None:
1✔
108
            return []
1✔
109
        return [d.get("party")[0].split("__")[0] for d in self.initial]
1✔
110

111
    def get_previous_party_affiliations_choices(self):
1✔
112
        """
113
        Return choices for previous_party_affilations field. By getting these on
114
        the formset and passing to the form, it saves the query for every
115
        individual form
116
        """
117
        if not self.ballot.is_welsh_run:
1✔
118
            return []
1✔
119

120
        parties = Party.objects.register("GB").active_in_last_year(
1✔
121
            date=self.ballot.election.election_date
122
        )
123
        return parties.values_list("ec_id", "name")
1✔
124

125

126
class BaseBulkAddReviewFormSet(BaseBulkAddFormSet):
1✔
127
    def suggested_people(
1✔
128
        self,
129
        person_name,
130
        new_party,
131
        new_election,
132
        new_name,
133
    ):
134
        if person_name:
1✔
135
            org_id = (
1✔
136
                new_election.organization.pk
137
                if new_election and new_election.organization
138
                else None
139
            )
140
            annotations = {
1✔
141
                "new_party": Value(new_party, output_field=CharField()),
142
                "new_organisation": Value(
143
                    org_id,
144
                    output_field=IntegerField(),
145
                ),
146
                "new_name": Value(new_name, output_field=CharField()),
147
            }
148

149
            qs = (
1✔
150
                search_person_by_name(person_name, synonym=True)
151
                .prefetch_related(
152
                    Prefetch(
153
                        "memberships",
154
                        queryset=Membership.objects.select_related(
155
                            "party",
156
                            "ballot",
157
                            "ballot__election",
158
                            "ballot__election__organization",
159
                        ),
160
                    ),
161
                )
162
                .annotate(**annotations)
163
            )
164
            return qs[:5]
1✔
NEW
165
        return None
×
166

167
    def add_fields(self, form, index):
1✔
168
        super().add_fields(form, index)
1✔
169
        if not form["name"].value():
1✔
170
            return
1✔
171
        suggestions = self.suggested_people(
1✔
172
            form["name"].value(),
173
            new_party=form.initial.get("party"),
174
            new_election=self.ballot.election,
175
            new_name=form.initial.get("name"),
176
        )
177
        form.fields["select_person"] = PersonSuggestionModelChoiceField(
1✔
178
            queryset=suggestions,
179
            widget=PersonSuggestionRadioSelect,
180
        )
181

182
        form.fields["select_person"].choices = [
1✔
183
            (
184
                "_new",
185
                SafeText(f'Add a new profile "{form.initial.get("name")}"'),
186
            )
187
        ] + list(form.fields["select_person"].choices)
188
        form.fields["select_person"].initial = "_new"
1✔
189

190
        form.fields["party"] = forms.CharField(
1✔
191
            widget=forms.HiddenInput(
192
                attrs={"readonly": "readonly", "class": "party-select"}
193
            ),
194
            required=False,
195
        )
196
        form.fields["sopn_last_name"] = StrippedCharField(
1✔
197
            widget=forms.HiddenInput(attrs={"readonly": "readonly"}),
198
            required=False,
199
        )
200
        form.fields["sopn_first_names"] = StrippedCharField(
1✔
201
            widget=forms.HiddenInput(attrs={"readonly": "readonly"}),
202
            required=False,
203
        )
204

205
    def clean(self):
1✔
206
        errors = []
1✔
207
        if not hasattr(self, "ballot"):
1✔
NEW
208
            return super().clean()
×
209

210
        if self.ballot.candidates_locked:
1✔
211
            raise ValidationError(
1✔
212
                "Candidates have already been locked for this ballot"
213
            )
214
        for form in self.forms:
1✔
215
            if not form.is_valid():
1✔
NEW
216
                continue
×
217
            form_data = form.cleaned_data
1✔
218
            if (
1✔
219
                "select_person" in form_data
220
                and form_data["select_person"] == "_new"
221
            ):
222
                continue
1✔
223
            qs = (
1✔
224
                Membership.objects.filter(ballot__election=self.ballot.election)
225
                .filter(person_id=form_data.get("select_person"))
226
                .exclude(ballot=self.ballot)
227
                .exclude(ballot__candidates_locked=True)
228
            )
229
            if qs.exists():
1✔
230
                errors.append(
1✔
231
                    forms.ValidationError(
232
                        "'{}' is marked as standing in another ballot for this "
233
                        "election. Check you're entering the correct "
234
                        "information for {}".format(
235
                            form_data["name"], self.ballot.post.label
236
                        )
237
                    )
238
                )
239
        if errors:
1✔
240
            raise forms.ValidationError(errors)
1✔
241
        return super().clean()
1✔
242

243

244
class NameOnlyPersonForm(forms.Form):
1✔
245
    name = StrippedCharField(
1✔
246
        label="Name (style: Ali McKay Smith not SMITH Ali McKay)",
247
        required=True,
248
        widget=forms.TextInput(
249
            attrs={"class": "person_name", "spellcheck": "false"}
250
        ),
251
    )
252
    ballot = ValidBallotField(
1✔
253
        widget=BallotInputWidget(attrs={"type": "hidden"})
254
    )
255
    sopn_last_name = StrippedCharField(
1✔
256
        widget=forms.HiddenInput(attrs={"readonly": "readonly"}),
257
        required=False,
258
    )
259
    sopn_first_names = StrippedCharField(
1✔
260
        widget=forms.HiddenInput(attrs={"readonly": "readonly"}),
261
        required=False,
262
    )
263

264

265
class BulkAddByPartyForm(NameOnlyPersonForm):
1✔
266
    biography = StrippedCharField(
1✔
267
        label="Statement to Voters",
268
        required=False,
269
        widget=forms.Textarea(
270
            attrs={"class": "person_biography"},
271
        ),
272
    )
273
    gender = StrippedCharField(
1✔
274
        label="Gender (e.g. “male”, “female”)", required=False
275
    )
276
    birth_date = forms.CharField(
1✔
277
        label="Year of birth (a four digit year)",
278
        required=False,
279
        widget=forms.NumberInput,
280
    )
281
    person_identifiers = PersonIdentifierFieldSet(
1✔
282
        label="Links and social media",
283
        required=False,
284
    )
285

286
    def clean_biography(self):
1✔
287
        bio = self.cleaned_data["biography"]
1✔
288
        return clean_biography(bio)
1✔
289

290
    def clean_birth_date(self):
1✔
291
        bd = self.cleaned_data["birth_date"]
1✔
292
        return clean_birth_date(bd)
1✔
293

294

295
class QuickAddSinglePersonForm(PopulatePartiesMixin, NameOnlyPersonForm):
1✔
296
    source = forms.CharField(required=True)
1✔
297
    party = PartyIdentifierField()
1✔
298
    previous_party_affiliations = PreviousPartyAffiliationsField()
1✔
299

300
    def __init__(self, **kwargs):
1✔
301
        previous_party_affiliations_choices = kwargs.pop(
1✔
302
            "previous_party_affiliations_choices", []
303
        )
304
        super().__init__(**kwargs)
1✔
305
        self.fields[
1✔
306
            "previous_party_affiliations"
307
        ].choices = previous_party_affiliations_choices
308

309
    def has_changed(self, *args, **kwargs):
1✔
310
        if "name" not in self.changed_data:
1✔
311
            return False
1✔
312
        return super().has_changed(*args, **kwargs)
1✔
313

314
    def clean(self):
1✔
315
        if (
1✔
316
            not self.cleaned_data["ballot"].is_welsh_run
317
            and self.cleaned_data["previous_party_affiliations"]
318
        ):
319
            raise ValidationError(
×
320
                "Previous party affiliations are invalid for this ballot"
321
            )
322
        return super().clean()
1✔
323

324

325
class ReviewSinglePersonNameOnlyForm(forms.Form):
1✔
326
    def __init__(self, *args, **kwargs):
1✔
327
        kwargs.pop("party_choices", None)
1✔
328
        super().__init__(*args, **kwargs)
1✔
329

330
    name = StrippedCharField(
1✔
331
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
332
    )
333

334

335
class ReviewBulkAddByPartyForm(ReviewSinglePersonNameOnlyForm):
1✔
336
    biography = StrippedCharField(
1✔
337
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
338
    )
339
    gender = StrippedCharField(
1✔
340
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
341
    )
342
    birth_date = forms.CharField(
1✔
343
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
344
    )
345
    person_identifiers = forms.CharField(
1✔
346
        required=False,
347
        widget=forms.HiddenInput(
348
            attrs={"readonly": "readonly"},
349
        ),
350
    )
351

352

353
class ReviewSinglePersonForm(ReviewSinglePersonNameOnlyForm):
1✔
354
    source = forms.CharField(
1✔
355
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
356
    )
357
    party_description = forms.ModelChoiceField(
1✔
358
        required=False,
359
        widget=forms.HiddenInput(),
360
        queryset=PartyDescription.objects.all(),
361
    )
362
    party_description_text = forms.CharField(
1✔
363
        required=False, widget=forms.HiddenInput()
364
    )
365
    previous_party_affiliations = forms.CharField(
1✔
366
        required=False, widget=forms.HiddenInput()
367
    )
368

369

370
BulkAddFormSetFactory = forms.formset_factory(
1✔
371
    QuickAddSinglePersonForm, extra=0, formset=BulkAddFormSet, can_delete=True
372
)
373

374

375
BulkAddReviewNameOnlyFormSet = forms.formset_factory(
1✔
376
    ReviewSinglePersonNameOnlyForm, extra=0, formset=BaseBulkAddReviewFormSet
377
)
378

379
BulkAddReviewFormSet = forms.formset_factory(
1✔
380
    ReviewSinglePersonForm, extra=0, formset=BaseBulkAddReviewFormSet
381
)
382

383

384
class BaseBulkAddByPartyFormset(forms.BaseFormSet):
1✔
385
    renderer = None
1✔
386

387
    def __init__(self, *args, **kwargs):
1✔
388
        self.ballot = kwargs["ballot"]
1✔
389
        kwargs["prefix"] = self.ballot.pk
1✔
390
        self.extra = self.ballot.winner_count
1✔
391
        del kwargs["ballot"]
1✔
392

393
        super().__init__(*args, **kwargs)
1✔
394

395
    def add_fields(self, form, index):
1✔
396
        super().add_fields(form, index)
1✔
397
        if self.ballot.election.party_lists_in_use:
1✔
398
            form.fields["party_list_position"] = forms.IntegerField(
×
399
                label="Position in party list ('1' for first, '2' for second, etc.)",
400
                min_value=1,
401
                required=False,
402
                initial=index + 1,
403
                widget=forms.NumberInput(attrs={"class": "party-position"}),
404
            )
405

406
        if self.ballot.candidates_locked:
1✔
407
            for name, field in form.fields.items():
×
408
                field.disabled = True
×
409
                form.fields[name] = field
×
410

411

412
BulkAddByPartyFormset = forms.formset_factory(
1✔
413
    BulkAddByPartyForm,
414
    extra=6,
415
    formset=BaseBulkAddByPartyFormset,
416
    can_delete=False,
417
)
418

419

420
class PartyBulkAddReviewFormSet(BaseBulkAddReviewFormSet):
1✔
421
    def __init__(self, *args, **kwargs):
1✔
422
        self.ballot = kwargs["ballot"]
1✔
423
        kwargs["prefix"] = self.ballot.pk
1✔
424
        self.extra = self.ballot.winner_count
1✔
425
        del kwargs["ballot"]
1✔
426

427
        super().__init__(*args, **kwargs)
1✔
428

429
    def clean(self):
1✔
430
        """
431
        Use this method to prevent users from adding candidates to ballots
432
        that can't have candidates added to them, like locked or cancelled
433
        ballots
434
        """
435
        if self.ballot.candidates_locked:
1✔
436
            raise ValidationError("Cannot add candidates to a locked ballot")
×
437
        if self.ballot.cancelled:
1✔
438
            raise ValidationError(
×
439
                "Cannot add candidates to a cancelled election"
440
            )
441

442
    def add_fields(self, form, index):
1✔
443
        super().add_fields(form, index)
1✔
444
        if self.ballot.election.party_lists_in_use:
1✔
445
            form.fields["party_list_position"] = forms.IntegerField(
×
446
                min_value=1, required=False, widget=forms.HiddenInput()
447
            )
448

449

450
PartyBulkAddReviewNameOnlyFormSet = forms.formset_factory(
1✔
451
    ReviewBulkAddByPartyForm, extra=0, formset=PartyBulkAddReviewFormSet
452
)
453

454

455
class SelectPartyForm(forms.Form):
1✔
456
    def __init__(self, *args, **kwargs):
1✔
457
        election = kwargs.pop("election")
1✔
458
        super().__init__(*args, **kwargs)
1✔
459

460
        self.election = election
1✔
461
        party_set_qs = (
1✔
462
            election.ballot_set.all()
463
            .order_by("post__party_set")
464
            .values_list("post__party_set__slug", flat=True)
465
        )
466

467
        registers = {p.upper() for p in party_set_qs}
1✔
468
        for register in registers:
1✔
469
            choices = Party.objects.register(register).party_choices(
1✔
470
                include_description_ids=True
471
            )
472
            field = PartyIdentifierField(
1✔
473
                label=f"{register} parties", choices=choices
474
            )
475

476
            field.fields[0].choices = choices
1✔
477
            field.widget.attrs["data-party-register"] = register
1✔
478
            field.widget.attrs["register"] = register
1✔
479
            self.fields[f"party_{register}"] = field
1✔
480

481
    def clean(self):
1✔
482
        form_data = self.cleaned_data
1✔
483
        if len([v for v in form_data.values() if v]) != 1:
1✔
484
            raise forms.ValidationError("Select one and only one party")
1✔
485

486
        form_data["party"] = [v for v in form_data.values() if v][0]
1✔
487
        return form_data
1✔
488

489

490
class AddByPartyForm(forms.Form):
1✔
491
    source = forms.CharField(required=True)
1✔
492

493

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