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

ephios-dev / ephios / 16494161539

24 Jul 2025 10:15AM UTC coverage: 85.613% (+1.9%) from 83.666%
16494161539

Pull #1601

github

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

3462 of 3947 branches covered (87.71%)

Branch coverage included in aggregate %.

58 of 61 new or added lines in 4 files covered. (95.08%)

2 existing lines in 1 file now uncovered.

12837 of 15091 relevant lines covered (85.06%)

0.85 hits per line

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

87.46
/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)
1✔
57
        complex_structure = self.shift.structure
1✔
58
        complex_structure._assume_cache()
1✔
59

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

66
        self.fields["unit_path"].choices = [("", _("auto"))]
1✔
67
        if qualified_blocks:
1✔
68
            self.fields["unit_path"].choices += [
1✔
69
                (
70
                    _("qualified"),
71
                    [(b["path"], b["display_with_path"]) for b in qualified_blocks],
72
                )
73
            ]
74
        if unqualified_blocks:
1✔
75
            self.fields["unit_path"].choices += [
1✔
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"):
1✔
82
            try:
1✔
83
                preferred_block = next(
1✔
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"]
1✔
90
            except StopIteration:
×
91
                pass  # preferred block not found
×
92
        if initial := self.instance.structure_data.get("dispatched_unit_path"):
1✔
93
            self.fields["unit_path"].initial = initial
1✔
94

95
    def save(self, commit=True):
1✔
96
        self.instance.structure_data["dispatched_unit_path"] = self.cleaned_data["unit_path"]
1✔
97
        super().save(commit)
1✔
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)
1✔
109
        self.fields["preferred_unit_path"].initial = self.instance.structure_data.get(
1✔
110
            "preferred_unit_path"
111
        )
112
        self.fields["preferred_unit_path"].required = (
1✔
113
            self.data.get("signup_choice") == "sign_up"
114
            and self.shift.structure.configuration.choose_preferred_unit
115
        )
116
        complex_structure = self.shift.structure
1✔
117
        complex_structure._assume_cache()
1✔
118
        self.fields["preferred_unit_path"].choices = [
1✔
119
            (b["path"], b["display_with_path"])
120
            for b in self.blocks_participant_qualifies_for(complex_structure._structure)
121
        ]
122
        unqualified_blocks = [
1✔
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:
1✔
128
            self.fields["preferred_unit_path"].help_text = _(
1✔
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[
1✔
134
            "preferred_unit_path"
135
        ]
136
        return super().save(commit)
1✔
137

138
    def blocks_participant_qualifies_for(self, structure):
1✔
139
        return atomic_block_participant_qualifies_for(structure, self.participant)
1✔
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:
1✔
188
            return item["label"] or item["building_block"].name
1✔
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
    @cached_property
1✔
210
    def confirmed_participants(self):
1✔
211
        return [
1✔
212
            participation.participant
213
            for participation in self.shift.participations.all()
214
            if participation.state == AbstractParticipation.States.CONFIRMED
215
        ]
216

217
    @cached_property
1✔
218
    def participations(self):
1✔
219
        """
220
        Returns a list of all participations in the shift that are sorted by confirmed-then-requested.
221
        Other states are dropped.
222
        """
223
        return list(
1✔
224
            sorted(
225
                filter(
226
                    lambda p: p.state
227
                    in {
228
                        AbstractParticipation.States.REQUESTED,
229
                        AbstractParticipation.States.CONFIRMED,
230
                    },
231
                    self.shift.participations.all(),
232
                ),
233
                key=attrgetter("state"),
234
                reverse=True,
235
            )
236
        )
237

238
    def _structure_match(self):
1✔
239
        participants = [participation.participant for participation in self.participations]
1✔
240
        all_positions, __ = build_structure_from_starting_blocks(
1✔
241
            self._starting_blocks, self.participations
242
        )
243
        self._matching = match_participants_to_positions(
1✔
244
            participants, all_positions, confirmed_participants=self.confirmed_participants
245
        )
246
        self._matching.attach_participations(self.participations)
1✔
247

248
        # let's work up the blocks again, but now with matching
249
        self._all_positions, self._structure = build_structure_from_starting_blocks(
1✔
250
            self._starting_blocks, self.participations, matching=self._matching
251
        )
252

253
        # for checking signup, we need a matching with only confirmed participations
254
        self._confirmed_only_matching = match_participants_to_positions(
1✔
255
            self.confirmed_participants,
256
            self._all_positions,
257
            confirmed_participants=self.confirmed_participants,
258
        )
259

260
        # we just have to add unpaired matches to the full stats
261
        self._signup_stats = self._structure["signup_stats"] + SignupStats.ZERO.replace(
1✔
262
            requested_count=sum(
263
                p.state == AbstractParticipation.States.REQUESTED
264
                for p in self._matching.unpaired_participations
265
            ),
266
            confirmed_count=sum(
267
                p.state == AbstractParticipation.States.CONFIRMED
268
                for p in self._matching.unpaired_participations
269
            ),
270
        )
271

272
    def _assume_cache(self):
1✔
273
        if not hasattr(self, "_cached_structure_match"):
1✔
274
            self._structure_match()
1✔
275
            self._cached_structure_match = True
1✔
276

277
    @cached_property
1✔
278
    def _starting_blocks(self):
1✔
279
        """
280
        Returns list of tuples of identifier, Building Block, label and optional.
281
        If there is no label, uses None. The identifier is a uuid kept per starting block
282
        and allows for later label/order change without losing disposition info.
283
        A block change is considered breaking and will trigger a change in identifier, because
284
        qualifications might not match afterwards.
285
        """
286
        qs = BuildingBlock.objects.all()
1✔
287
        id_to_block = {
1✔
288
            block.id: block
289
            for block in qs.filter(
290
                id__in=[unit["building_block"] for unit in self.configuration.starting_blocks]
291
            )
292
        }
293
        starting_blocks = []
1✔
294
        for unit in self.configuration.starting_blocks:
1✔
295
            if unit["building_block"] not in id_to_block:
1✔
296
                continue  # block missing from DB
1✔
297
            starting_blocks.append(
1✔
298
                (
299
                    unit["uuid"],
300
                    id_to_block[unit["building_block"]],
301
                    unit["label"],
302
                    unit["optional"],
303
                )
304
            )
305
        return starting_blocks
1✔
306

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

317
    def get_signup_stats(self) -> "SignupStats":
1✔
318
        self._assume_cache()
1✔
319
        return self._signup_stats
1✔
320

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

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

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

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

376

377
def _build_display_name_long(block_name, composed_label, number):
1✔
378
    if composed_label and composed_label != block_name:
1✔
379
        return f"{composed_label} - {block_name} #{number}"
1✔
380
    return f"{block_name} #{number}"
1✔
381

382

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

389

390
def iter_atomic_blocks(structure):
1✔
391
    for sub_block in structure["sub_blocks"]:
1✔
392
        if not sub_block["is_composite"]:
1✔
393
            yield sub_block
1✔
394
        else:
395
            yield from iter_atomic_blocks(sub_block)
1✔
396

397

398
def build_structure_from_starting_blocks(starting_blocks, participations, matching=None):
1✔
399
    """
400
    Create a tree structure from the given starting blocks, providing a plethora of information
401
    used for display, matching and signup validation.
402
    If a matching is provided, the signup stats will have correct participation counts,
403
    but only for matched participants.
404
    """
405
    root_path = "root."  # build a dot-seperated path used to identify blocks and positions.
1✔
406
    all_positions = []  # collect all positions in a list
1✔
407
    # The root block is "virtual", always composite and contains the starting blocks as sub_blocks.
408
    structure = {
1✔
409
        "is_composite": True,
410
        "positions": [],
411
        "position_match_ids": [],
412
        "sub_blocks": [],
413
        "path": root_path,
414
        "optional": False,
415
        "level": 0,
416
        "name": "ROOT",
417
        "qualification_label": "",
418
    }
419
    # The block usage counter is used to assign indices to usages of atomic blocks so they can be
420
    # differentiated in the matching algorithm as well as when displaying units.
421
    block_usage_counter = defaultdict(partial(itertools.count, 1))
1✔
422
    for identifier, block, label, optional in starting_blocks:
1✔
423
        positions, sub_structure = _search_block(
1✔
424
            block,
425
            path=f"{root_path}{identifier}.",
426
            level=1,
427
            path_optional=optional,
428
            required_qualifications=set(),
429
            participations=participations,
430
            block_usage_counter=block_usage_counter,
431
            matching=matching,
432
            parents=[],
433
            composed_label=label,
434
        )
435
        all_positions.extend(positions)
1✔
436
        structure["sub_blocks"].append(sub_structure)
1✔
437
    structure["signup_stats"] = SignupStats.reduce(
1✔
438
        [s["signup_stats"] for s in structure["sub_blocks"]]
439
    )
440
    return all_positions, structure
1✔
441

442

443
def _search_block(
1✔
444
    block: BuildingBlock,
445
    path: str,
446
    level: int,
447
    required_qualifications: set,
448
    path_optional: bool,
449
    participations: list[AbstractParticipation],
450
    block_usage_counter,
451
    matching: Matching,
452
    parents: list,
453
    composed_label: Optional[str] = None,
454
):  # pylint: disable=too-many-locals
455
    """
456
    Recursively build a tree structure from the given block.
457
    Return all positions and a dict describing the structure at this block.
458
    """
459
    required_here = set(required_qualifications)
1✔
460
    for requirement in block.qualification_requirements.all():
1!
461
        if not requirement.everyone:
×
462
            # "at least one" is not supported
463
            raise ValueError("unsupported requirement")
×
464
        required_here |= set(requirement.qualifications.all())
×
465

466
    all_positions = []
1✔
467
    number = next(block_usage_counter[block.name])
1✔
468
    display_long = _build_display_name_long(block.name, composed_label, number)
1✔
469
    structure = {
1✔
470
        "is_composite": block.is_composite(),
471
        "positions": [],
472
        "participations": [],
473
        "sub_blocks": [],
474
        "path": path,
475
        "level": level,
476
        "optional": path_optional,
477
        "name": block.name,
478
        "label": composed_label,
479
        "display_short": composed_label or block.name,
480
        "display_long": display_long,
481
        "display_with_path": _build_display_path(parents, display_long),
482
        "number": number,
483
        "qualification_label": ", ".join(q.abbreviation for q in required_here),
484
        "qualification_ids": {q.id for q in required_here},
485
        "parents": parents,
486
        "signup_stats": SignupStats.ZERO,
487
    }
488
    if block.is_composite():
1✔
489
        for composition in (
1✔
490
            block.sub_compositions.all()
491
            .select_related(
492
                "sub_block",
493
            )
494
            .prefetch_related(
495
                "sub_block__positions__qualifications",
496
                "sub_block__qualification_requirements__qualifications",
497
                "sub_block__sub_compositions",
498
            )
499
        ):
500
            positions, sub_structure = _search_block(
1✔
501
                block=composition.sub_block,
502
                path=f"{path}{composition.id}.",
503
                level=level + 1,
504
                required_qualifications=required_here,
505
                path_optional=path_optional or composition.optional,
506
                block_usage_counter=block_usage_counter,
507
                participations=participations,
508
                matching=matching,
509
                parents=[structure, *parents],
510
                composed_label=composition.label,
511
            )
512
            structure["signup_stats"] += sub_structure["signup_stats"]
1✔
513
            all_positions.extend(positions)
1✔
514
            structure["sub_blocks"].append(sub_structure)
1✔
515
    else:
516
        _build_atomic_block_structure(
1✔
517
            all_positions,
518
            block,
519
            matching,
520
            block_usage_counter,
521
            participations,
522
            path,
523
            path_optional,
524
            required_here,
525
            structure,
526
        )
527
    return all_positions, structure
1✔
528

529

530
def _build_atomic_block_structure(
1✔
531
    all_positions,
532
    block,
533
    matching,
534
    block_usage_counter,
535
    participations,
536
    path,
537
    path_optional,
538
    required_here,
539
    structure,
540
):  # pylint: disable=too-many-locals
541
    designated_for = {
1✔
542
        p.participant
543
        for p in participations
544
        if p.structure_data.get("dispatched_unit_path") == path
545
    }
546
    preferred_by = {
1✔
547
        p.participant for p in participations if p.structure_data.get("preferred_unit_path") == path
548
    }
549
    for block_position in block.positions.all():
1✔
550
        match_id = _build_position_id(path, is_more=False, position_id=block_position.id)
1✔
551
        label = block_position.label or ", ".join(
1✔
552
            q.abbreviation for q in block_position.qualifications.all()
553
        )
554
        required = not (block_position.optional or path_optional)
1✔
555
        p = Position(
1✔
556
            id=match_id,
557
            required_qualifications=required_here | set(block_position.qualifications.all()),
558
            designated_for=designated_for,
559
            preferred_by=preferred_by,
560
            required=required,
561
            label=label,
562
            aux_score=1,
563
        )
564
        participation = matching.participation_for_position(match_id) if matching else None
1✔
565
        has_confirmed_participation = (
1✔
566
            participation is not None
567
            and participation.state == AbstractParticipation.States.CONFIRMED
568
        )
569
        structure["signup_stats"] += SignupStats.ZERO.replace(
1✔
570
            min_count=int(required),
571
            max_count=1,
572
            missing=int(required and not has_confirmed_participation),
573
            free=int(not has_confirmed_participation),
574
            requested_count=bool(
575
                participation and participation.state == AbstractParticipation.States.REQUESTED
576
            ),
577
            confirmed_count=has_confirmed_participation,
578
        )
579
        all_positions.append(p)
1✔
580
        structure["positions"].append(p)
1✔
581
        structure["participations"].append(participation)
1✔
582

583
    # build derivative positions for "allow_more" participants
584
    allow_more_count = int(block.allow_more) * (
1✔
585
        len(participations) + 1
586
    )  # 1 extra in case of signup matching check
587
    for _ in range(allow_more_count):
1!
NEW
588
        opt_match_id = _build_position_id(
×
589
            path, is_more=True, position_id=next(block_usage_counter[str(block.id)])
590
        )
UNCOV
591
        p = Position(
×
592
            id=opt_match_id,
593
            required_qualifications=required_here,
594
            preferred_by=preferred_by,
595
            designated_for=designated_for,
596
            aux_score=0,
597
            required=False,  # allow_more -> always optional
598
            label=block.name,
599
        )
600
        participation = matching.participation_for_position(opt_match_id) if matching else None
×
601
        structure["signup_stats"] += SignupStats.ZERO.replace(
×
602
            min_count=0,
603
            max_count=None,  # allow_more -> always free
604
            missing=0,
605
            free=None,
606
            requested_count=bool(
607
                participation and participation.state == AbstractParticipation.States.REQUESTED
608
            ),
609
            confirmed_count=bool(
610
                participation and participation.state == AbstractParticipation.States.CONFIRMED
611
            ),
612
        )
613
        all_positions.append(p)
×
614
        structure["positions"].append(p)
×
615
        structure["participations"].append(participation)
×
616

617
    for _ in range(max(0, len(designated_for) - len(block.positions.all()) - allow_more_count)):
1!
618
        # if more designated participants than we have positions, we need to add placeholder anyway
NEW
619
        opt_match_id = _build_position_id(
×
620
            path, is_more=True, position_id=next(block_usage_counter[str(block.id)])
621
        )
UNCOV
622
        p = Position(
×
623
            id=opt_match_id,
624
            required_qualifications=required_here,
625
            preferred_by=preferred_by,
626
            designated_for=designated_for,
627
            aux_score=0,
628
            required=False,  # designated -> always optional
629
            label=block.name,
630
            designation_only=True,
631
        )
632
        participation = matching.participation_for_position(opt_match_id) if matching else None
×
633
        structure["signup_stats"] += SignupStats.ZERO.replace(
×
634
            min_count=0,
635
            max_count=0,  # designated overflow -> runs over max
636
            missing=0,
637
            free=0,
638
            requested_count=bool(
639
                participation and participation.state == AbstractParticipation.States.REQUESTED
640
            ),
641
            confirmed_count=bool(
642
                participation and participation.state == AbstractParticipation.States.CONFIRMED
643
            ),
644
        )
645
        all_positions.append(p)
×
646
        structure["positions"].append(p)
×
647
        structure["participations"].append(participation)
×
648

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

652

653
def _build_position_id(path, is_more, position_id):
1✔
654
    """
655
    Return a string identifying a position, give by a path identifying the atomic block,
656
     some modestring differentiating the origin of the position id, and an id for the position.
657
    """
658
    # For use of Position.pk, use "pk" as modestring, for other positions "more". This way,
659
    # there are no collisions of count and pk.
660
    modestring = "more" if is_more else "pk"
1✔
661
    return f"{path}{modestring}-{position_id}"
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