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

ephios-dev / ephios / 16446154427

22 Jul 2025 01:45PM UTC coverage: 83.666% (-0.5%) from 84.167%
16446154427

push

github

felixrindt
use uuid for complex starting block id

3162 of 3743 branches covered (84.48%)

Branch coverage included in aggregate %.

9 of 34 new or added lines in 8 files covered. (26.47%)

231 existing lines in 8 files now uncovered.

12584 of 15077 relevant lines covered (83.46%)

0.83 hits per line

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

22.94
/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✔
UNCOV
35
    available_qualification_ids = set(q.id for q in participant.collect_all_qualifications())
×
UNCOV
36
    return [
×
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)
×
UNCOV
57
        complex_structure = self.shift.structure
×
UNCOV
58
        complex_structure._assume_cache()
×
59

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

UNCOV
66
        self.fields["unit_path"].choices = [("", _("auto"))]
×
UNCOV
67
        if qualified_blocks:
×
UNCOV
68
            self.fields["unit_path"].choices += [
×
69
                (
70
                    _("qualified"),
71
                    [(b["path"], b["display_with_path"]) for b in qualified_blocks],
72
                )
73
            ]
UNCOV
74
        if unqualified_blocks:
×
UNCOV
75
            self.fields["unit_path"].choices += [
×
76
                (
77
                    _("unqualified"),
78
                    [(b["path"], b["display_with_path"]) for b in unqualified_blocks],
79
                )
80
            ]
UNCOV
81
        if preferred_unit_path := self.instance.structure_data.get("preferred_unit_path"):
×
UNCOV
82
            try:
×
UNCOV
83
                preferred_block = next(
×
84
                    filter(
85
                        lambda b: b["path"] == preferred_unit_path,
86
                        all_blocks,
87
                    )
88
                )
NEW
89
                self.preferred_unit_name = preferred_block["display_with_path"]
×
UNCOV
90
            except StopIteration:
×
UNCOV
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✔
UNCOV
96
        self.instance.structure_data["dispatched_unit_path"] = self.cleaned_data["unit_path"]
×
UNCOV
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)
×
UNCOV
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
        )
UNCOV
116
        complex_structure = self.shift.structure
×
UNCOV
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
        ]
UNCOV
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
        ]
UNCOV
127
        if unqualified_blocks:
×
UNCOV
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✔
UNCOV
133
        self.instance.structure_data["preferred_unit_path"] = self.cleaned_data[
×
134
            "preferred_unit_path"
135
        ]
UNCOV
136
        return super().save(commit)
×
137

138
    def blocks_participant_qualifies_for(self, structure):
1✔
UNCOV
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✔
UNCOV
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✔
UNCOV
187
        try:
×
NEW
188
            return item["label"] or item["building_block"].name
×
UNCOV
189
        except AttributeError:
×
190
            # building block is an id
UNCOV
191
            try:
×
UNCOV
192
                return str(BuildingBlock.objects.get(id=item["building_block"]).name)
×
UNCOV
193
            except BuildingBlock.DoesNotExist:
×
UNCOV
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]
×
UNCOV
211
        confirmed_participants = [
×
212
            participation.participant
213
            for participation in participations
214
            if participation.state == AbstractParticipation.States.CONFIRMED
215
        ]
UNCOV
216
        all_positions, structure = convert_blocks_to_positions(
×
217
            self._starting_blocks, participations
218
        )
UNCOV
219
        matching = match_participants_to_positions(
×
220
            participants, all_positions, confirmed_participants=confirmed_participants
221
        )
222
        matching.attach_participations(participations)
×
223

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

229
        # for checking signup, we need a matching with only confirmed participations
UNCOV
230
        confirmed_only_matching = match_participants_to_positions(
×
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
UNCOV
237
        signup_stats = structure["signup_stats"] + SignupStats.ZERO.replace(
×
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
        )
UNCOV
253
        return matching, confirmed_only_matching, all_positions, structure, signup_stats
×
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()
×
265
        id_to_block = {
×
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 = []
×
UNCOV
272
        for unit in self.configuration.starting_blocks:
×
UNCOV
273
            if unit["building_block"] not in id_to_block:
×
UNCOV
274
                continue  # block missing from DB
×
UNCOV
275
            starting_blocks.append(
×
276
                (
277
                    unit["uuid"],
278
                    id_to_block[unit["building_block"]],
279
                    unit["label"],
280
                    unit["optional"],
281
                )
282
            )
UNCOV
283
        return starting_blocks
×
284

285
    def _assume_cache(self):
1✔
286
        if not hasattr(self, "_cached_work"):
×
287
            participations = [
×
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
            ]
UNCOV
295
            (
×
296
                self._matching,
297
                self._confirmed_only_matching,
298
                self._all_positions,
299
                self._structure,
300
                self._signup_stats,
301
            ) = self._match(participations)
UNCOV
302
            self._cached_work = True
×
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
        """
UNCOV
308
        kwargs = super().get_shift_state_context_data(request, **kwargs)
×
309
        self._assume_cache()
×
310
        kwargs["matching"] = self._matching
×
311
        kwargs["structure"] = self._structure
×
UNCOV
312
        return kwargs
×
313

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

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

UNCOV
326
        if not strict_mode:
×
327
            # check if the participant fulfills any of the requirements
UNCOV
328
            if atomic_block_participant_qualifies_for(self._structure, participant):
×
UNCOV
329
                return
×
330
        else:
331
            # check if the participant can be matched into already confirmed participations
UNCOV
332
            confirmed_participants = [p.participant for p in confirmed_participations]
×
UNCOV
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
            )
UNCOV
338
            if len(matching_with_this_participant.pairings) > len(
×
339
                self._confirmed_only_matching.pairings
340
            ):
UNCOV
341
                return
×
UNCOV
342
        if (free := self._signup_stats.free) and free > 0:
×
UNCOV
343
            raise ParticipantUnfitError(_("You are not qualified."))
×
UNCOV
344
        raise SignupDisallowedError(_("The maximum number of participants is reached."))
×
345

346
    def get_checkers(self):
1✔
UNCOV
347
        return super().get_checkers() + [
×
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✔
UNCOV
355
        self._assume_cache()
×
UNCOV
356
        export_data = []
×
UNCOV
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:
×
UNCOV
363
                    continue
×
UNCOV
364
                export_data.append(
×
365
                    {
366
                        "participation": participation,
367
                        "required_qualifications": position.required_qualifications,
368
                        "description": block["display_with_path"],
369
                    }
370
                )
UNCOV
371
        return export_data
×
372

373

374
def _build_display_name_long(block_name, composed_label, number):
1✔
NEW
375
    if composed_label and composed_label != block_name:
×
NEW
376
        return f"{composed_label} - {block_name} #{number}"
×
NEW
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
NEW
382
    if parents := [s["display_short"] for s in reversed(parents)]:
×
NEW
383
        return f"{display_long} ({' » '.join(parents)})"
×
NEW
384
    return display_long
×
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
UNCOV
399
    required_here = set(required_qualifications)
×
400
    for requirement in block.qualification_requirements.all():
×
UNCOV
401
        if not requirement.everyone:
×
402
            # at least one is not supported
UNCOV
403
            raise ValueError("unsupported requirement")
×
UNCOV
404
        required_here |= set(requirement.qualifications.all())
×
405

UNCOV
406
    all_positions = []
×
NEW
407
    number = next(opt_counter[block.name])
×
NEW
408
    display_long = _build_display_name_long(block.name, composed_label, number)
×
409
    structure = {
×
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():
×
UNCOV
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
            )
UNCOV
452
            structure["signup_stats"] += sub_structure["signup_stats"]
×
UNCOV
453
            all_positions.extend(positions)
×
UNCOV
454
            structure["sub_blocks"].append(sub_structure)
×
455
    else:
UNCOV
456
        _build_atomic_block_structure(
×
457
            all_positions,
458
            block,
459
            matching,
460
            opt_counter,
461
            participations,
462
            path,
463
            path_optional,
464
            required_here,
465
            structure,
466
        )
UNCOV
467
    return all_positions, structure
×
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
UNCOV
481
    designated_for = {
×
482
        p.participant
483
        for p in participations
484
        if p.structure_data.get("dispatched_unit_path") == path
485
    }
UNCOV
486
    preferred_by = {
×
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():
×
490
        match_id = _build_position_id(block, block_position.id, path)
×
UNCOV
491
        label = block_position.label or ", ".join(
×
492
            q.abbreviation for q in block_position.qualifications.all()
493
        )
UNCOV
494
        required = not (block_position.optional or path_optional)
×
UNCOV
495
        p = Position(
×
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
×
UNCOV
505
        has_confirmed_participation = (
×
506
            participation is not None
507
            and participation.state == AbstractParticipation.States.CONFIRMED
508
        )
UNCOV
509
        structure["signup_stats"] += SignupStats.ZERO.replace(
×
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)
×
520
        structure["positions"].append(p)
×
521
        structure["participations"].append(participation)
×
522

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

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

585

586
def _build_position_id(block, path, position_id):
1✔
587
    """
588
    For a given block, a counter providing running numbers and a path of blocks,
589
    construct an ID for the matching positions.
590
    """
UNCOV
591
    return f"{path}{block.uuid}-opt-{position_id}"
×
592

593

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

632

633
def iter_atomic_blocks(structure):
1✔
UNCOV
634
    for sub_block in structure["sub_blocks"]:
×
UNCOV
635
        if not sub_block["is_composite"]:
×
UNCOV
636
            yield sub_block
×
637
        else:
UNCOV
638
            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