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

ephios-dev / ephios / 16470385304

23 Jul 2025 12:16PM UTC coverage: 84.885% (+1.2%) from 83.666%
16470385304

Pull #1601

github

web-flow
Merge 8ad1ea566 into d16320b67
Pull Request #1601: Fix issues with "unit is full" in predefined units structure

3361 of 3886 branches covered (86.49%)

Branch coverage included in aggregate %.

28 of 31 new or added lines in 3 files covered. (90.32%)

2 existing lines in 1 file now uncovered.

12751 of 15095 relevant lines covered (84.47%)

0.84 hits per line

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

60.53
/ephios/plugins/complexsignup/structure.py
1
import itertools
1✔
2
import logging
1✔
3
import uuid
1✔
4
from collections import defaultdict
1✔
5
from functools import cached_property, partial
1✔
6
from operator import attrgetter
1✔
7
from typing import Optional
1✔
8

9
from django import forms
1✔
10
from django.utils.translation import gettext
1✔
11
from django.utils.translation import gettext_lazy as _
1✔
12
from django_select2.forms import ModelSelect2Widget
1✔
13

14
from ephios.core.models import AbstractParticipation
1✔
15
from ephios.core.services.matching import Matching, Position, match_participants_to_positions
1✔
16
from ephios.core.signup.disposition import BaseDispositionParticipationForm
1✔
17
from ephios.core.signup.flow.participant_validation import (
1✔
18
    ParticipantUnfitError,
19
    SignupDisallowedError,
20
)
21
from ephios.core.signup.forms import BaseSignupForm
1✔
22
from ephios.core.signup.participants import AbstractParticipant
1✔
23
from ephios.core.signup.stats import SignupStats
1✔
24
from ephios.core.signup.structure.base import BaseShiftStructure
1✔
25
from ephios.plugins.baseshiftstructures.structure.common import MinimumAgeMixin
1✔
26
from ephios.plugins.baseshiftstructures.structure.group_common import (
1✔
27
    AbstractGroupBasedStructureConfigurationForm,
28
)
29
from ephios.plugins.complexsignup.models import BuildingBlock
1✔
30

31
logger = logging.getLogger(__name__)
1✔
32

33

34
def atomic_block_participant_qualifies_for(structure, participant: AbstractParticipant):
1✔
35
    available_qualification_ids = set(q.id for q in participant.collect_all_qualifications())
1✔
36
    return [
1✔
37
        block
38
        for block in iter_atomic_blocks(structure)
39
        if block["qualification_ids"] <= available_qualification_ids
40
        and any(
41
            {q.id for q in position.required_qualifications} <= available_qualification_ids
42
            and not position.designation_only
43
            for position in block["positions"]
44
        )
45
    ]
46

47

48
class ComplexDispositionParticipationForm(BaseDispositionParticipationForm):
1✔
49
    disposition_participation_template = "complexsignup/fragment_participation.html"
1✔
50
    unit_path = forms.ChoiceField(
1✔
51
        label=_("Unit"),
52
        required=False,
53
    )
54

55
    def __init__(self, **kwargs):
1✔
56
        super().__init__(**kwargs)
×
57
        complex_structure = self.shift.structure
×
58
        complex_structure._assume_cache()
×
59

60
        qualified_blocks = atomic_block_participant_qualifies_for(
×
61
            complex_structure._structure, self.instance.participant
62
        )
63
        all_blocks = list(iter_atomic_blocks(complex_structure._structure))
×
64
        unqualified_blocks = [b for b in all_blocks if b not in qualified_blocks]
×
65

66
        self.fields["unit_path"].choices = [("", _("auto"))]
×
67
        if qualified_blocks:
×
68
            self.fields["unit_path"].choices += [
×
69
                (
70
                    _("qualified"),
71
                    [(b["path"], b["display_with_path"]) for b in qualified_blocks],
72
                )
73
            ]
74
        if unqualified_blocks:
×
75
            self.fields["unit_path"].choices += [
×
76
                (
77
                    _("unqualified"),
78
                    [(b["path"], b["display_with_path"]) for b in unqualified_blocks],
79
                )
80
            ]
81
        if preferred_unit_path := self.instance.structure_data.get("preferred_unit_path"):
×
82
            try:
×
83
                preferred_block = next(
×
84
                    filter(
85
                        lambda b: b["path"] == preferred_unit_path,
86
                        all_blocks,
87
                    )
88
                )
89
                self.preferred_unit_name = preferred_block["display_with_path"]
×
90
            except StopIteration:
×
91
                pass  # preferred block not found
×
92
        if initial := self.instance.structure_data.get("dispatched_unit_path"):
×
93
            self.fields["unit_path"].initial = initial
×
94

95
    def save(self, commit=True):
1✔
96
        self.instance.structure_data["dispatched_unit_path"] = self.cleaned_data["unit_path"]
×
97
        super().save(commit)
×
98

99

100
class ComplexSignupForm(BaseSignupForm):
1✔
101
    preferred_unit_path = forms.ChoiceField(
1✔
102
        label=_("Preferred Unit"),
103
        widget=forms.RadioSelect,
104
        required=False,
105
    )
106

107
    def __init__(self, *args, **kwargs):
1✔
108
        super().__init__(*args, **kwargs)
×
109
        self.fields["preferred_unit_path"].initial = self.instance.structure_data.get(
×
110
            "preferred_unit_path"
111
        )
112
        self.fields["preferred_unit_path"].required = (
×
113
            self.data.get("signup_choice") == "sign_up"
114
            and self.shift.structure.configuration.choose_preferred_unit
115
        )
116
        complex_structure = self.shift.structure
×
117
        complex_structure._assume_cache()
×
118
        self.fields["preferred_unit_path"].choices = [
×
119
            (b["path"], b["display_with_path"])
120
            for b in self.blocks_participant_qualifies_for(complex_structure._structure)
121
        ]
122
        unqualified_blocks = [
×
123
            b
124
            for b in iter_atomic_blocks(complex_structure._structure)
125
            if b not in self.blocks_participant_qualifies_for(complex_structure._structure)
126
        ]
127
        if unqualified_blocks:
×
128
            self.fields["preferred_unit_path"].help_text = _(
×
129
                "You don't qualify for {blocks}."
130
            ).format(blocks=", ".join(set(b["display_with_path"] for b in unqualified_blocks)))
131

132
    def save(self, commit=True):
1✔
133
        self.instance.structure_data["preferred_unit_path"] = self.cleaned_data[
×
134
            "preferred_unit_path"
135
        ]
136
        return super().save(commit)
×
137

138
    def blocks_participant_qualifies_for(self, structure):
1✔
139
        return atomic_block_participant_qualifies_for(structure, self.participant)
×
140

141

142
class StartingBlockForm(forms.Form):
1✔
143
    building_block = forms.ModelChoiceField(
1✔
144
        label=_("Unit"),
145
        widget=ModelSelect2Widget(
146
            model=BuildingBlock,
147
            search_fields=["name__icontains"],
148
            attrs={"data-minimum-input-length": 0},
149
        ),
150
        queryset=BuildingBlock.objects.all(),
151
    )
152
    label = forms.CharField(label=_("Label"), required=False)
1✔
153
    optional = forms.BooleanField(label=_("optional"), required=False)
1✔
154
    uuid = forms.CharField(widget=forms.HiddenInput, required=False)
1✔
155

156
    def clean_uuid(self):
1✔
157
        return self.cleaned_data.get("uuid") or uuid.uuid4()
×
158

159

160
StartingBlocksFormset = forms.formset_factory(
1✔
161
    StartingBlockForm, can_delete=True, min_num=1, validate_min=1, extra=0
162
)
163

164

165
class ComplexConfigurationForm(AbstractGroupBasedStructureConfigurationForm):
1✔
166
    template_name = "complexsignup/configuration_form.html"
1✔
167
    choose_preferred_team = None  # renamed
1✔
168
    choose_preferred_unit = forms.BooleanField(
1✔
169
        label=_("Participants must provide a preferred unit"),
170
        help_text=_("Participants will be asked during signup."),
171
        widget=forms.CheckboxInput,
172
        required=False,
173
        initial=False,
174
    )
175
    starting_blocks = forms.Field(
1✔
176
        label=_("Units"),
177
        widget=forms.HiddenInput,
178
        required=False,
179
    )
180
    formset_data_field_name = "starting_blocks"
1✔
181

182
    def get_formset_class(self):
1✔
183
        return StartingBlocksFormset
×
184

185
    @classmethod
1✔
186
    def format_formset_item(cls, item):
1✔
187
        try:
×
188
            return item["label"] or item["building_block"].name
×
189
        except AttributeError:
×
190
            # building block is an id
191
            try:
×
192
                return str(BuildingBlock.objects.get(id=item["building_block"]).name)
×
193
            except BuildingBlock.DoesNotExist:
×
194
                return gettext("Deleted unit")
×
195

196

197
class ComplexShiftStructure(
1✔
198
    MinimumAgeMixin,
199
    BaseShiftStructure,
200
):
201
    slug = "complex"
1✔
202
    verbose_name = _("Preconfigured Structure (experimental)")
1✔
203
    description = _("Use preconfigured elements to build a custom structure.")
1✔
204
    shift_state_template_name = "complexsignup/shift_state.html"
1✔
205
    configuration_form_class = ComplexConfigurationForm
1✔
206
    disposition_participation_form_class = ComplexDispositionParticipationForm
1✔
207
    signup_form_class = ComplexSignupForm
1✔
208

209
    def _match(self, participations):
1✔
210
        participants = [participation.participant for participation in participations]
1✔
211
        confirmed_participants = [
1✔
212
            participation.participant
213
            for participation in participations
214
            if participation.state == AbstractParticipation.States.CONFIRMED
215
        ]
216
        all_positions, structure = convert_blocks_to_positions(
1✔
217
            self._starting_blocks, participations
218
        )
219
        matching = match_participants_to_positions(
1✔
220
            participants, all_positions, confirmed_participants=confirmed_participants
221
        )
222
        matching.attach_participations(participations)
1✔
223

224
        # let's work up the blocks again, but now with matching
225
        all_positions, structure = convert_blocks_to_positions(
1✔
226
            self._starting_blocks, participations, matching=matching
227
        )
228

229
        # for checking signup, we need a matching with only confirmed participations
230
        confirmed_only_matching = match_participants_to_positions(
1✔
231
            confirmed_participants,
232
            all_positions,
233
            confirmed_participants=confirmed_participants,
234
        )
235

236
        # we just have to add unpaired matches to the full stats
237
        signup_stats = structure["signup_stats"] + SignupStats.ZERO.replace(
1✔
238
            requested_count=len(
239
                [
240
                    p
241
                    for p in matching.unpaired_participations
242
                    if p.state == AbstractParticipation.States.REQUESTED
243
                ]
244
            ),
245
            confirmed_count=len(
246
                [
247
                    p
248
                    for p in matching.unpaired_participations
249
                    if p.state == AbstractParticipation.States.CONFIRMED
250
                ]
251
            ),
252
        )
253
        return matching, confirmed_only_matching, all_positions, structure, signup_stats
1✔
254

255
    @cached_property
1✔
256
    def _starting_blocks(self):
1✔
257
        """
258
        Returns list of tuples of identifier, Building Block, label and optional.
259
        If there is no label, uses None. The identifier is a uuid kept per starting block
260
        and allows for later label/order change without losing disposition info.
261
        A block change is considered breaking and will trigger a change in identifier, because
262
        qualifications might not match afterwards.
263
        """
264
        qs = BuildingBlock.objects.all()
1✔
265
        id_to_block = {
1✔
266
            block.id: block
267
            for block in qs.filter(
268
                id__in=[unit["building_block"] for unit in self.configuration.starting_blocks]
269
            )
270
        }
271
        starting_blocks = []
1✔
272
        for unit in self.configuration.starting_blocks:
1✔
273
            if unit["building_block"] not in id_to_block:
1!
274
                continue  # block missing from DB
×
275
            starting_blocks.append(
1✔
276
                (
277
                    unit["uuid"],
278
                    id_to_block[unit["building_block"]],
279
                    unit["label"],
280
                    unit["optional"],
281
                )
282
            )
283
        return starting_blocks
1✔
284

285
    def _assume_cache(self):
1✔
286
        if not hasattr(self, "_cached_work"):
1✔
287
            participations = [
1✔
288
                p
289
                for p in sorted(
290
                    self.shift.participations.all(), key=attrgetter("state"), reverse=True
291
                )
292
                if p.state
293
                in {AbstractParticipation.States.REQUESTED, AbstractParticipation.States.CONFIRMED}
294
            ]
295
            (
1✔
296
                self._matching,
297
                self._confirmed_only_matching,
298
                self._all_positions,
299
                self._structure,
300
                self._signup_stats,
301
            ) = self._match(participations)
302
            self._cached_work = True
1✔
303

304
    def get_shift_state_context_data(self, request, **kwargs):
1✔
305
        """
306
        Additionally to the context of the event detail view, provide context for rendering `shift_state_template_name`.
307
        """
308
        kwargs = super().get_shift_state_context_data(request, **kwargs)
1✔
309
        self._assume_cache()
1✔
310
        kwargs["matching"] = self._matching
1✔
311
        kwargs["structure"] = self._structure
1✔
312
        return kwargs
1✔
313

314
    def get_signup_stats(self) -> "SignupStats":
1✔
315
        self._assume_cache()
1✔
316
        return self._signup_stats
1✔
317

318
    def check_qualifications(self, shift, participant, strict_mode=True):
1✔
319
        confirmed_participations = [
1✔
320
            p
321
            for p in shift.participations.all()
322
            if p.state == AbstractParticipation.States.CONFIRMED
323
        ]
324
        self._assume_cache()
1✔
325

326
        if not strict_mode:
1!
327
            # check if the participant fulfills any of the requirements
328
            if atomic_block_participant_qualifies_for(self._structure, participant):
1!
329
                return
×
330
        else:
331
            # check if the participant can be matched into already confirmed participations
332
            confirmed_participants = [p.participant for p in confirmed_participations]
×
333
            if participant in confirmed_participants:
×
334
                return
×
335
            matching_with_this_participant = match_participants_to_positions(
×
336
                confirmed_participants + [participant], self._all_positions
337
            )
338
            if len(matching_with_this_participant.pairings) > len(
×
339
                self._confirmed_only_matching.pairings
340
            ):
341
                return
×
342
        if (free := self._signup_stats.free) and free > 0:
1!
343
            raise ParticipantUnfitError(_("You are not qualified."))
1✔
344
        raise SignupDisallowedError(_("The maximum number of participants is reached."))
×
345

346
    def get_checkers(self):
1✔
347
        return super().get_checkers() + [
1✔
348
            partial(
349
                self.check_qualifications,
350
                strict_mode=not self.shift.signup_flow.uses_requested_state,
351
            )
352
        ]
353

354
    def get_list_export_data(self):
1✔
355
        self._assume_cache()
×
356
        export_data = []
×
357
        for block in iter_atomic_blocks(self._structure):
×
358
            for position, participation in zip(
×
359
                block["positions"],
360
                block["participations"],
361
            ):
362
                if not participation and not position.required:
×
363
                    continue
×
364
                export_data.append(
×
365
                    {
366
                        "participation": participation,
367
                        "required_qualifications": position.required_qualifications,
368
                        "description": block["display_with_path"],
369
                    }
370
                )
371
        return export_data
×
372

373

374
def _build_display_name_long(block_name, composed_label, number):
1✔
375
    if composed_label and composed_label != block_name:
1!
376
        return f"{composed_label} - {block_name} #{number}"
1✔
377
    return f"{block_name} #{number}"
×
378

379

380
def _build_display_path(parents, display_long):
1✔
381
    # put the display name first, in case the whole path gets cut off
382
    if parents := [s["display_short"] for s in reversed(parents)]:
1!
383
        return f"{display_long} ({' » '.join(parents)})"
×
384
    return display_long
1✔
385

386

387
def _search_block(
1✔
388
    block: BuildingBlock,
389
    path: str,
390
    level: int,
391
    required_qualifications: set,
392
    path_optional: bool,
393
    participations: list[AbstractParticipation],
394
    opt_counter,
395
    matching: Matching,
396
    parents: list,
397
    composed_label: Optional[str] = None,
398
):  # pylint: disable=too-many-locals
399
    required_here = set(required_qualifications)
1✔
400
    for requirement in block.qualification_requirements.all():
1!
401
        if not requirement.everyone:
×
402
            # at least one is not supported
403
            raise ValueError("unsupported requirement")
×
404
        required_here |= set(requirement.qualifications.all())
×
405

406
    all_positions = []
1✔
407
    number = next(opt_counter[block.name])
1✔
408
    display_long = _build_display_name_long(block.name, composed_label, number)
1✔
409
    structure = {
1✔
410
        "is_composite": block.is_composite(),
411
        "positions": [],
412
        "participations": [],
413
        "sub_blocks": [],
414
        "path": path,
415
        "level": level,
416
        "optional": path_optional,
417
        "name": block.name,
418
        "label": composed_label,
419
        "display_short": composed_label or block.name,
420
        "display_long": display_long,
421
        "display_with_path": _build_display_path(parents, display_long),
422
        "number": number,
423
        "qualification_label": ", ".join(q.abbreviation for q in required_here),
424
        "qualification_ids": {q.id for q in required_here},
425
        "parents": parents,
426
        "signup_stats": SignupStats.ZERO,
427
    }
428
    if block.is_composite():
1!
429
        for composition in (
×
430
            block.sub_compositions.all()
431
            .select_related(
432
                "sub_block",
433
            )
434
            .prefetch_related(
435
                "sub_block__positions__qualifications",
436
                "sub_block__qualification_requirements__qualifications",
437
                "sub_block__sub_compositions",
438
            )
439
        ):
440
            positions, sub_structure = _search_block(
×
441
                block=composition.sub_block,
442
                path=f"{path}{composition.id}.",
443
                level=level + 1,
444
                required_qualifications=required_here,
445
                path_optional=path_optional or composition.optional,
446
                opt_counter=opt_counter,
447
                participations=participations,
448
                matching=matching,
449
                parents=[structure, *parents],
450
                composed_label=composition.label,
451
            )
452
            structure["signup_stats"] += sub_structure["signup_stats"]
×
453
            all_positions.extend(positions)
×
454
            structure["sub_blocks"].append(sub_structure)
×
455
    else:
456
        _build_atomic_block_structure(
1✔
457
            all_positions,
458
            block,
459
            matching,
460
            opt_counter,
461
            participations,
462
            path,
463
            path_optional,
464
            required_here,
465
            structure,
466
        )
467
    return all_positions, structure
1✔
468

469

470
def _build_atomic_block_structure(
1✔
471
    all_positions,
472
    block,
473
    matching,
474
    opt_counter,
475
    participations,
476
    path,
477
    path_optional,
478
    required_here,
479
    structure,
480
):  # pylint: disable=too-many-locals
481
    designated_for = {
1✔
482
        p.participant
483
        for p in participations
484
        if p.structure_data.get("dispatched_unit_path") == path
485
    }
486
    preferred_by = {
1✔
487
        p.participant for p in participations if p.structure_data.get("preferred_unit_path") == path
488
    }
489
    for block_position in block.positions.all():
1✔
490
        match_id = _build_position_id(path, is_more=False, position_id=block_position.id)
1✔
491
        label = block_position.label or ", ".join(
1✔
492
            q.abbreviation for q in block_position.qualifications.all()
493
        )
494
        required = not (block_position.optional or path_optional)
1✔
495
        p = Position(
1✔
496
            id=match_id,
497
            required_qualifications=required_here | set(block_position.qualifications.all()),
498
            designated_for=designated_for,
499
            preferred_by=preferred_by,
500
            required=required,
501
            label=label,
502
            aux_score=1,
503
        )
504
        participation = matching.participation_for_position(match_id) if matching else None
1✔
505
        has_confirmed_participation = (
1✔
506
            participation is not None
507
            and participation.state == AbstractParticipation.States.CONFIRMED
508
        )
509
        structure["signup_stats"] += SignupStats.ZERO.replace(
1✔
510
            min_count=int(required),
511
            max_count=1,
512
            missing=int(required and not has_confirmed_participation),
513
            free=int(not has_confirmed_participation),
514
            requested_count=bool(
515
                participation and participation.state == AbstractParticipation.States.REQUESTED
516
            ),
517
            confirmed_count=has_confirmed_participation,
518
        )
519
        all_positions.append(p)
1✔
520
        structure["positions"].append(p)
1✔
521
        structure["participations"].append(participation)
1✔
522

523
    # build derivative positions for "allow_more" participants
524
    allow_more_count = int(block.allow_more) * (
1✔
525
        len(participations) + 1
526
    )  # 1 extra in case of signup matching check
527
    for _ in range(allow_more_count):
1!
NEW
528
        opt_match_id = _build_position_id(
×
529
            path, is_more=True, position_id=next(opt_counter[str(block.id)])
530
        )
UNCOV
531
        p = Position(
×
532
            id=opt_match_id,
533
            required_qualifications=required_here,
534
            preferred_by=preferred_by,
535
            designated_for=designated_for,
536
            aux_score=0,
537
            required=False,  # allow_more -> always optional
538
            label=block.name,
539
        )
540
        participation = matching.participation_for_position(opt_match_id) if matching else None
×
541
        structure["signup_stats"] += SignupStats.ZERO.replace(
×
542
            min_count=0,
543
            max_count=None,  # allow_more -> always free
544
            missing=0,
545
            free=None,
546
            requested_count=bool(
547
                participation and participation.state == AbstractParticipation.States.REQUESTED
548
            ),
549
            confirmed_count=bool(
550
                participation and participation.state == AbstractParticipation.States.CONFIRMED
551
            ),
552
        )
553
        all_positions.append(p)
×
554
        structure["positions"].append(p)
×
555
        structure["participations"].append(participation)
×
556

557
    for _ in range(max(0, len(designated_for) - len(block.positions.all()) - allow_more_count)):
1!
558
        # if more designated participants than we have positions, we need to add placeholder anyway
NEW
559
        opt_match_id = _build_position_id(
×
560
            path, is_more=True, position_id=next(opt_counter[str(block.id)])
561
        )
UNCOV
562
        p = Position(
×
563
            id=opt_match_id,
564
            required_qualifications=required_here,
565
            preferred_by=preferred_by,
566
            designated_for=designated_for,
567
            aux_score=0,
568
            required=False,  # designated -> always optional
569
            label=block.name,
570
            designation_only=True,
571
        )
572
        participation = matching.participation_for_position(opt_match_id) if matching else None
×
573
        structure["signup_stats"] += SignupStats.ZERO.replace(
×
574
            min_count=0,
575
            max_count=0,  # designated overflow -> runs over max
576
            missing=0,
577
            free=0,
578
            requested_count=bool(
579
                participation and participation.state == AbstractParticipation.States.REQUESTED
580
            ),
581
            confirmed_count=bool(
582
                participation and participation.state == AbstractParticipation.States.CONFIRMED
583
            ),
584
        )
585
        all_positions.append(p)
×
586
        structure["positions"].append(p)
×
587
        structure["participations"].append(participation)
×
588

589
    # used to check if this atomic block can be considered "free"
590
    structure["has_undesignated_positions"] = len(structure["positions"]) > len(designated_for)
1✔
591

592

593
def _build_position_id(path, is_more, position_id):
1✔
594
    """
595
    Return a string identifying a position, give by a path identifying the atomic block,
596
     some modestring differentiating the origin of the position id, and an id for the position.
597
    """
598
    # For use of Position.pk, use "pk" as modestring, for other positions "more". This way,
599
    # there are no collisions of count and pk.
600
    modestring = "more" if is_more else "pk"
1✔
601
    return f"{path}{modestring}-{position_id}"
1✔
602

603

604
def convert_blocks_to_positions(starting_blocks, participations, matching=None):
1✔
605
    """
606
    If a matching is provided, the signup stats will have correct participation counts
607
    """
608
    root_path = "root."
1✔
609
    all_positions = []
1✔
610
    structure = {
1✔
611
        "is_composite": True,  # root block is "virtual" and always composite
612
        "positions": [],
613
        "position_match_ids": [],
614
        "sub_blocks": [],
615
        "path": root_path,
616
        "optional": False,
617
        "level": 0,
618
        "name": "ROOT",
619
        "qualification_label": "",
620
    }
621
    opt_counter = defaultdict(partial(itertools.count, 1))
1✔
622
    for identifier, block, label, optional in starting_blocks:
1✔
623
        positions, sub_structure = _search_block(
1✔
624
            block,
625
            path=f"{root_path}{identifier}.",
626
            level=1,
627
            path_optional=optional,
628
            required_qualifications=set(),
629
            participations=participations,
630
            opt_counter=opt_counter,
631
            matching=matching,
632
            parents=[],
633
            composed_label=label,
634
        )
635
        all_positions.extend(positions)
1✔
636
        structure["sub_blocks"].append(sub_structure)
1✔
637
    structure["signup_stats"] = SignupStats.reduce(
1✔
638
        [s["signup_stats"] for s in structure["sub_blocks"]]
639
    )
640
    return all_positions, structure
1✔
641

642

643
def iter_atomic_blocks(structure):
1✔
644
    for sub_block in structure["sub_blocks"]:
1✔
645
        if not sub_block["is_composite"]:
1!
646
            yield sub_block
1✔
647
        else:
648
            yield from iter_atomic_blocks(sub_block)
×
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