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

DemocracyClub / yournextrepresentative / d0421b27-f043-41ee-a5ee-c3a83bf3c576

14 Nov 2025 11:13AM UTC coverage: 70.14% (+0.04%) from 70.101%
d0421b27-f043-41ee-a5ee-c3a83bf3c576

Pull #2613

circleci

chris48s
fix last bulk add test

I've fixed this from 2 angles
1. Election.organization is nullable,
   so the code should handle that case
2. The test is simulating an general election,
   so the election and ballot should all be
   assigned to House of Commons as the org
Pull Request #2613: WIP django 5

820 of 1282 branches covered (63.96%)

Branch coverage included in aggregate %.

76 of 81 new or added lines in 13 files covered. (93.83%)

2 existing lines in 1 file now uncovered.

7648 of 10791 relevant lines covered (70.87%)

0.71 hits per line

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

94.57
/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 get_form_kwargs(self, index):
1✔
81
        kwargs = super().get_form_kwargs(index)
1✔
82
        kwargs["party_choices"] = self.parties
1✔
83
        kwargs[
1✔
84
            "previous_party_affiliations_choices"
85
        ] = self.previous_party_affiliations_choices
86
        return kwargs
1✔
87

88
    @property
1✔
89
    def initial_party_ids(self):
1✔
90
        """
91
        Returns a list of any party ID's that are included in initial data
92
        """
93
        if self.initial is None:
1✔
94
            return []
1✔
95
        return [d.get("party")[0].split("__")[0] for d in self.initial]
1✔
96

97
    def get_previous_party_affiliations_choices(self):
1✔
98
        """
99
        Return choices for previous_party_affilations field. By getting these on
100
        the formset and passing to the form, it saves the query for every
101
        individual form
102
        """
103
        if not self.ballot.is_welsh_run:
1✔
104
            return []
1✔
105

106
        parties = Party.objects.register("GB").active_in_last_year(
1✔
107
            date=self.ballot.election.election_date
108
        )
109
        return parties.values_list("ec_id", "name")
1✔
110

111

112
class BaseBulkAddReviewFormSet(BaseBulkAddFormSet):
1✔
113
    def suggested_people(
1✔
114
        self,
115
        person_name,
116
        new_party,
117
        new_election,
118
        new_name,
119
    ):
120
        if person_name:
1✔
121
            org_id = (
1✔
122
                new_election.organization.pk
123
                if new_election and new_election.organization
124
                else None
125
            )
126
            annotations = {
1✔
127
                "new_party": Value(new_party, output_field=CharField()),
128
                "new_organisation": Value(
129
                    org_id,
130
                    output_field=IntegerField(),
131
                ),
132
                "new_name": Value(new_name, output_field=CharField()),
133
            }
134

135
            qs = (
1✔
136
                search_person_by_name(person_name, synonym=True)
137
                .prefetch_related(
138
                    Prefetch(
139
                        "memberships",
140
                        queryset=Membership.objects.select_related(
141
                            "party",
142
                            "ballot",
143
                            "ballot__election",
144
                            "ballot__election__organization",
145
                        ),
146
                    ),
147
                )
148
                .annotate(**annotations)
149
            )
150
            return qs[:5]
1✔
NEW
151
        return None
×
152

153
    def add_fields(self, form, index):
1✔
154
        super().add_fields(form, index)
1✔
155
        if not form["name"].value():
1✔
156
            return
1✔
157
        suggestions = self.suggested_people(
1✔
158
            form["name"].value(),
159
            new_party=form.initial.get("party"),
160
            new_election=self.ballot.election,
161
            new_name=form.initial.get("name"),
162
        )
163
        form.fields["select_person"] = PersonSuggestionModelChoiceField(
1✔
164
            queryset=suggestions,
165
            widget=PersonSuggestionRadioSelect,
166
        )
167

168
        form.fields["select_person"].choices = [
1✔
169
            (
170
                "_new",
171
                SafeText(f'Add a new profile "{form.initial.get("name")}"'),
172
            )
173
        ] + list(form.fields["select_person"].choices)
174
        form.fields["select_person"].initial = "_new"
1✔
175

176
        form.fields["party"] = forms.CharField(
1✔
177
            widget=forms.HiddenInput(
178
                attrs={"readonly": "readonly", "class": "party-select"}
179
            ),
180
            required=False,
181
        )
182

183
    def clean(self):
1✔
184
        errors = []
1✔
185
        if not hasattr(self, "ballot"):
1✔
186
            return super().clean()
×
187

188
        if self.ballot.candidates_locked:
1✔
189
            raise ValidationError(
1✔
190
                "Candidates have already been locked for this ballot"
191
            )
192
        for form in self.forms:
1✔
193
            if not form.is_valid():
1✔
NEW
194
                continue
×
195
            form_data = form.cleaned_data
1✔
196
            if (
1✔
197
                "select_person" in form_data
198
                and form_data["select_person"] == "_new"
199
            ):
200
                continue
1✔
201
            qs = (
1✔
202
                Membership.objects.filter(ballot__election=self.ballot.election)
203
                .filter(person_id=form_data.get("select_person"))
204
                .exclude(ballot=self.ballot)
205
                .exclude(ballot__candidates_locked=True)
206
            )
207
            if qs.exists():
1✔
208
                errors.append(
1✔
209
                    forms.ValidationError(
210
                        "'{}' is marked as standing in another ballot for this "
211
                        "election. Check you're entering the correct "
212
                        "information for {}".format(
213
                            form_data["name"], self.ballot.post.label
214
                        )
215
                    )
216
                )
217
        if errors:
1✔
218
            raise forms.ValidationError(errors)
1✔
219
        return super().clean()
1✔
220

221

222
class NameOnlyPersonForm(forms.Form):
1✔
223
    name = StrippedCharField(
1✔
224
        label="Name (style: Ali McKay Smith not SMITH Ali McKay)",
225
        required=True,
226
        widget=forms.TextInput(
227
            attrs={"class": "person_name", "spellcheck": "false"}
228
        ),
229
    )
230
    ballot = ValidBallotField(
1✔
231
        widget=BallotInputWidget(attrs={"type": "hidden"})
232
    )
233

234

235
class BulkAddByPartyForm(NameOnlyPersonForm):
1✔
236
    biography = StrippedCharField(
1✔
237
        label="Statement to Voters",
238
        required=False,
239
        widget=forms.Textarea(
240
            attrs={"class": "person_biography"},
241
        ),
242
    )
243
    gender = StrippedCharField(
1✔
244
        label="Gender (e.g. “male”, “female”)", required=False
245
    )
246
    birth_date = forms.CharField(
1✔
247
        label="Year of birth (a four digit year)",
248
        required=False,
249
        widget=forms.NumberInput,
250
    )
251
    person_identifiers = PersonIdentifierFieldSet(
1✔
252
        label="Links and social media",
253
        required=False,
254
    )
255

256
    def clean_biography(self):
1✔
257
        bio = self.cleaned_data["biography"]
1✔
258
        return clean_biography(bio)
1✔
259

260
    def clean_birth_date(self):
1✔
261
        bd = self.cleaned_data["birth_date"]
1✔
262
        return clean_birth_date(bd)
1✔
263

264

265
class QuickAddSinglePersonForm(PopulatePartiesMixin, NameOnlyPersonForm):
1✔
266
    source = forms.CharField(required=True)
1✔
267
    party = PartyIdentifierField()
1✔
268
    previous_party_affiliations = PreviousPartyAffiliationsField()
1✔
269

270
    def __init__(self, **kwargs):
1✔
271
        previous_party_affiliations_choices = kwargs.pop(
1✔
272
            "previous_party_affiliations_choices", []
273
        )
274
        super().__init__(**kwargs)
1✔
275
        self.fields[
1✔
276
            "previous_party_affiliations"
277
        ].choices = previous_party_affiliations_choices
278

279
    def has_changed(self, *args, **kwargs):
1✔
280
        if "name" not in self.changed_data:
1✔
281
            return False
1✔
282
        return super().has_changed(*args, **kwargs)
1✔
283

284
    def clean(self):
1✔
285
        if (
1✔
286
            not self.cleaned_data["ballot"].is_welsh_run
287
            and self.cleaned_data["previous_party_affiliations"]
288
        ):
289
            raise ValidationError(
×
290
                "Previous party affiliations are invalid for this ballot"
291
            )
292
        return super().clean()
1✔
293

294

295
class ReviewSinglePersonNameOnlyForm(forms.Form):
1✔
296
    def __init__(self, *args, **kwargs):
1✔
297
        kwargs.pop("party_choices", None)
1✔
298
        super().__init__(*args, **kwargs)
1✔
299

300
    name = StrippedCharField(
1✔
301
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
302
    )
303

304

305
class ReviewBulkAddByPartyForm(ReviewSinglePersonNameOnlyForm):
1✔
306
    biography = StrippedCharField(
1✔
307
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
308
    )
309
    gender = StrippedCharField(
1✔
310
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
311
    )
312
    birth_date = forms.CharField(
1✔
313
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
314
    )
315
    person_identifiers = forms.CharField(
1✔
316
        required=False,
317
        widget=forms.HiddenInput(
318
            attrs={"readonly": "readonly"},
319
        ),
320
    )
321

322

323
class ReviewSinglePersonForm(ReviewSinglePersonNameOnlyForm):
1✔
324
    source = forms.CharField(
1✔
325
        required=False, widget=forms.HiddenInput(attrs={"readonly": "readonly"})
326
    )
327
    party_description = forms.ModelChoiceField(
1✔
328
        required=False,
329
        widget=forms.HiddenInput(),
330
        queryset=PartyDescription.objects.all(),
331
    )
332
    party_description_text = forms.CharField(
1✔
333
        required=False, widget=forms.HiddenInput()
334
    )
335
    previous_party_affiliations = forms.CharField(
1✔
336
        required=False, widget=forms.HiddenInput()
337
    )
338

339

340
BulkAddFormSetFactory = forms.formset_factory(
1✔
341
    QuickAddSinglePersonForm, extra=15, formset=BulkAddFormSet, can_delete=True
342
)
343

344

345
BulkAddReviewNameOnlyFormSet = forms.formset_factory(
1✔
346
    ReviewSinglePersonNameOnlyForm, extra=0, formset=BaseBulkAddReviewFormSet
347
)
348

349
BulkAddReviewFormSet = forms.formset_factory(
1✔
350
    ReviewSinglePersonForm, extra=0, formset=BaseBulkAddReviewFormSet
351
)
352

353

354
class BaseBulkAddByPartyFormset(forms.BaseFormSet):
1✔
355
    renderer = None
1✔
356

357
    def __init__(self, *args, **kwargs):
1✔
358
        self.ballot = kwargs["ballot"]
1✔
359
        kwargs["prefix"] = self.ballot.pk
1✔
360
        self.extra = self.ballot.winner_count
1✔
361
        del kwargs["ballot"]
1✔
362

363
        super().__init__(*args, **kwargs)
1✔
364

365
    def add_fields(self, form, index):
1✔
366
        super().add_fields(form, index)
1✔
367
        if self.ballot.election.party_lists_in_use:
1✔
368
            form.fields["party_list_position"] = forms.IntegerField(
×
369
                label="Position in party list ('1' for first, '2' for second, etc.)",
370
                min_value=1,
371
                required=False,
372
                initial=index + 1,
373
                widget=forms.NumberInput(attrs={"class": "party-position"}),
374
            )
375

376
        if self.ballot.candidates_locked:
1✔
377
            for name, field in form.fields.items():
×
378
                field.disabled = True
×
379
                form.fields[name] = field
×
380

381

382
BulkAddByPartyFormset = forms.formset_factory(
1✔
383
    BulkAddByPartyForm,
384
    extra=6,
385
    formset=BaseBulkAddByPartyFormset,
386
    can_delete=False,
387
)
388

389

390
class PartyBulkAddReviewFormSet(BaseBulkAddReviewFormSet):
1✔
391
    def __init__(self, *args, **kwargs):
1✔
392
        self.ballot = kwargs["ballot"]
1✔
393
        kwargs["prefix"] = self.ballot.pk
1✔
394
        self.extra = self.ballot.winner_count
1✔
395
        del kwargs["ballot"]
1✔
396

397
        super().__init__(*args, **kwargs)
1✔
398

399
    def clean(self):
1✔
400
        """
401
        Use this method to prevent users from adding candidates to ballots
402
        that can't have candidates added to them, like locked or cancelled
403
        ballots
404
        """
405
        if self.ballot.candidates_locked:
1✔
406
            raise ValidationError("Cannot add candidates to a locked ballot")
×
407
        if self.ballot.cancelled:
1✔
408
            raise ValidationError(
×
409
                "Cannot add candidates to a cancelled election"
410
            )
411

412
    def add_fields(self, form, index):
1✔
413
        super().add_fields(form, index)
1✔
414
        if self.ballot.election.party_lists_in_use:
1✔
415
            form.fields["party_list_position"] = forms.IntegerField(
×
416
                min_value=1, required=False, widget=forms.HiddenInput()
417
            )
418

419

420
PartyBulkAddReviewNameOnlyFormSet = forms.formset_factory(
1✔
421
    ReviewBulkAddByPartyForm, extra=0, formset=PartyBulkAddReviewFormSet
422
)
423

424

425
class SelectPartyForm(forms.Form):
1✔
426
    def __init__(self, *args, **kwargs):
1✔
427
        election = kwargs.pop("election")
1✔
428
        super().__init__(*args, **kwargs)
1✔
429

430
        self.election = election
1✔
431
        party_set_qs = (
1✔
432
            election.ballot_set.all()
433
            .order_by("post__party_set")
434
            .values_list("post__party_set__slug", flat=True)
435
        )
436

437
        registers = {p.upper() for p in party_set_qs}
1✔
438
        for register in registers:
1✔
439
            choices = Party.objects.register(register).party_choices(
1✔
440
                include_description_ids=True
441
            )
442
            field = PartyIdentifierField(
1✔
443
                label=f"{register} parties", choices=choices
444
            )
445

446
            field.fields[0].choices = choices
1✔
447
            field.widget.attrs["data-party-register"] = register
1✔
448
            field.widget.attrs["register"] = register
1✔
449
            self.fields[f"party_{register}"] = field
1✔
450

451
    def clean(self):
1✔
452
        form_data = self.cleaned_data
1✔
453
        if len([v for v in form_data.values() if v]) != 1:
1✔
454
            raise forms.ValidationError("Select one and only one party")
1✔
455

456
        form_data["party"] = [v for v in form_data.values() if v][0]
1✔
457
        return form_data
1✔
458

459

460
class AddByPartyForm(forms.Form):
1✔
461
    source = forms.CharField(required=True)
1✔
462

463

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