Skip to content

Commit 68aae66

Browse files
committed
Merge remote-tracking branch 'origin/main' into feature/eja-eli-279-tighten-up-gitlab-permissions
2 parents 6903d4b + bd7a6f9 commit 68aae66

20 files changed

Lines changed: 1466 additions & 314 deletions

File tree

poetry.lock

Lines changed: 8 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ python = "^3.13"
2424
flask = {extras = ["async"], version = "^3.1.1"}
2525
httpx = "^0.28.1"
2626
yarl = "^1.18.3"
27-
pydantic = "^2.10.6"
27+
pydantic = "^2.11.7"
2828
asgiref = "^3.8.1"
2929
boto3 = "^1.37.3"
3030
botocore = "^1.38.32"

src/eligibility_signposting_api/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from mangum.types import LambdaContext, LambdaEvent
99

1010
from eligibility_signposting_api import repos, services
11-
from eligibility_signposting_api.config import config, init_logging
11+
from eligibility_signposting_api.config.config import config, init_logging
1212
from eligibility_signposting_api.error_handler import handle_exception
1313
from eligibility_signposting_api.views import eligibility_blueprint
1414

src/eligibility_signposting_api/config/__init__.py

Whitespace-only changes.
File renamed without changes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
MAGIC_COHORT_LABEL = "elid_all_people"

src/eligibility_signposting_api/model/eligibility.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
ConditionName = NewType("ConditionName", str)
1313

1414
RuleName = NewType("RuleName", str)
15-
RuleResult = NewType("RuleResult", str)
15+
RuleDescription = NewType("RuleDescription", str)
1616

1717

1818
class RuleType(StrEnum):
@@ -60,28 +60,28 @@ def best(*statuses: Status) -> Status:
6060
class Reason:
6161
rule_type: RuleType
6262
rule_name: RuleName
63-
rule_result: RuleResult
63+
rule_description: RuleDescription | None
6464

6565

6666
@dataclass
6767
class Condition:
6868
condition_name: ConditionName
6969
status: Status
70-
cohort_results: list[CohortResult]
70+
cohort_results: list[CohortGroupResult]
7171

7272

7373
@dataclass
74-
class CohortResult:
74+
class CohortGroupResult:
7575
cohort_code: str
7676
status: Status
7777
reasons: list[Reason]
78-
description: str
78+
description: str | None
7979

8080

8181
@dataclass
8282
class IterationResult:
8383
status: Status
84-
cohort_results: list[CohortResult]
84+
cohort_results: list[CohortGroupResult]
8585

8686

8787
@dataclass

src/eligibility_signposting_api/model/rules.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
from pydantic import BaseModel, Field, field_serializer, field_validator, model_validator
1212

13+
from eligibility_signposting_api.config.contants import MAGIC_COHORT_LABEL
14+
1315
if typing.TYPE_CHECKING: # pragma: no cover
1416
from pydantic import SerializationInfo
1517

@@ -85,14 +87,18 @@ class RuleAttributeLevel(StrEnum):
8587

8688

8789
class IterationCohort(BaseModel):
88-
cohort_label: CohortLabel | None = Field(None, alias="CohortLabel")
89-
cohort_group: CohortGroup | None = Field(None, alias="CohortGroup")
90+
cohort_label: CohortLabel = Field(alias="CohortLabel")
91+
cohort_group: CohortGroup = Field(alias="CohortGroup")
9092
positive_description: Description | None = Field(None, alias="PositiveDescription")
9193
negative_description: Description | None = Field(None, alias="NegativeDescription")
9294
priority: int | None = Field(None, alias="Priority")
9395

9496
model_config = {"populate_by_name": True, "extra": "ignore"}
9597

98+
@cached_property
99+
def is_magic_cohort(self) -> bool:
100+
return self.cohort_label.upper() == MAGIC_COHORT_LABEL.upper()
101+
96102

97103
class IterationRule(BaseModel):
98104
type: RuleType = Field(..., alias="Type")
@@ -107,14 +113,14 @@ class IterationRule(BaseModel):
107113
attribute_target: RuleAttributeTarget | None = Field(None, alias="AttributeTarget")
108114
rule_stop: RuleStop = Field(RuleStop(False), alias="RuleStop") # noqa: FBT003
109115

116+
model_config = {"populate_by_name": True, "extra": "ignore"}
117+
110118
@field_validator("rule_stop", mode="before")
111119
def parse_yn_to_bool(cls, v: str | bool) -> bool: # noqa: N805
112120
if isinstance(v, str):
113121
return v.upper() == "Y"
114122
return v
115123

116-
model_config = {"populate_by_name": True, "extra": "ignore"}
117-
118124

119125
class Iteration(BaseModel):
120126
id: IterationID = Field(..., alias="ID")

src/eligibility_signposting_api/repos/factory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from wireup import Inject, service
88
from yarl import URL
99

10-
from eligibility_signposting_api.config import AwsAccessKey, AwsRegion, AwsSecretAccessKey
10+
from eligibility_signposting_api.config.config import AwsAccessKey, AwsRegion, AwsSecretAccessKey
1111

1212
logger = logging.getLogger(__name__)
1313

src/eligibility_signposting_api/services/calculators/eligibility_calculator.py

Lines changed: 97 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from _operator import attrgetter
4+
from collections import defaultdict
45
from collections.abc import Collection, Iterable, Iterator, Mapping
56
from dataclasses import dataclass, field
67
from itertools import groupby
@@ -13,16 +14,17 @@
1314

1415
from eligibility_signposting_api.model import eligibility, rules
1516
from eligibility_signposting_api.model.eligibility import (
16-
CohortResult,
17+
CohortGroupResult,
1718
Condition,
1819
ConditionName,
1920
IterationResult,
2021
Status,
2122
)
22-
from eligibility_signposting_api.services.calculators.rule_calculator import RuleCalculator
23+
from eligibility_signposting_api.services.calculators.rule_calculator import (
24+
RuleCalculator,
25+
)
2326

2427
Row = Collection[Mapping[str, Any]]
25-
magic_cohort = "elid_all_people"
2628

2729

2830
@service
@@ -49,23 +51,39 @@ def campaigns_grouped_by_condition_name(
4951
) -> Iterator[tuple[eligibility.ConditionName, list[rules.CampaignConfig]]]:
5052
"""Generator function to iterate over campaign groups by condition name."""
5153
for condition_name, campaign_group in groupby(
52-
sorted(self.active_campaigns, key=attrgetter("target")), key=attrgetter("target")
54+
sorted(self.active_campaigns, key=attrgetter("target")),
55+
key=attrgetter("target"),
5356
):
5457
yield condition_name, list(campaign_group)
5558

5659
@property
5760
def person_cohorts(self) -> set[str]:
5861
cohorts_row: Mapping[str, dict[str, dict[str, dict[str, Any]]]] = next(
59-
(row for row in self.person_data if row.get("ATTRIBUTE_TYPE") == "COHORTS"), {}
62+
(row for row in self.person_data if row.get("ATTRIBUTE_TYPE") == "COHORTS"),
63+
{},
6064
)
6165
return set(cohorts_row.get("COHORT_MAP", {}).get("cohorts", {}).get("M", {}).keys())
6266

6367
@staticmethod
64-
def get_best_cohort(cohort_results: dict[str, CohortResult]) -> tuple[Status, list[CohortResult]]:
68+
def get_the_best_cohort_memberships(
69+
cohort_results: dict[str, CohortGroupResult],
70+
) -> tuple[Status, list[CohortGroupResult]]:
6571
if not cohort_results:
6672
return eligibility.Status.not_eligible, []
73+
6774
best_status = eligibility.Status.best(*[result.status for result in cohort_results.values()])
6875
best_cohorts = [result for result in cohort_results.values() if result.status == best_status]
76+
77+
best_cohorts = [
78+
CohortGroupResult(
79+
cohort_code=cc.cohort_code,
80+
status=cc.status,
81+
reasons=cc.reasons,
82+
description=(cc.description or "").strip() if cc.description else "",
83+
)
84+
for cc in best_cohorts
85+
]
86+
6987
return best_status, best_cohorts
7088

7189
@staticmethod
@@ -98,28 +116,11 @@ def evaluate_eligibility(self) -> eligibility.EligibilityStatus:
98116
iteration_results: dict[str, IterationResult] = {}
99117

100118
for active_iteration in [cc.current_iteration for cc in campaign_group]:
101-
cohort_results: dict[str, CohortResult] = {}
102-
103-
filter_rules, suppression_rules = self.get_rules_by_type(active_iteration)
104-
for cohort in sorted(active_iteration.iteration_cohorts, key=attrgetter("priority")):
105-
# Base Eligibility - check
106-
if cohort.cohort_label in self.person_cohorts or cohort.cohort_label == magic_cohort:
107-
# Eligibility - check
108-
if self.is_eligible_by_filter_rules(cohort, cohort_results, filter_rules):
109-
# Actionability - evaluation
110-
self.evaluate_suppression_rules(cohort, cohort_results, suppression_rules)
111-
112-
# Not base eligible
113-
elif cohort.cohort_label is not None:
114-
cohort_results[cohort.cohort_label] = CohortResult(
115-
cohort.cohort_group if cohort.cohort_group else cohort.cohort_label,
116-
Status.not_eligible,
117-
[],
118-
str(cohort.negative_description),
119-
)
119+
cohort_results: dict[str, CohortGroupResult] = self.get_cohort_results(active_iteration)
120120

121121
# Determine Result between cohorts - get the best
122-
status, best_cohorts = self.get_best_cohort(cohort_results)
122+
status, best_cohorts = self.get_the_best_cohort_memberships(cohort_results)
123+
123124
iteration_results[active_iteration.name] = IterationResult(status, best_cohorts)
124125

125126
# Determine results between iterations - get the best
@@ -130,20 +131,71 @@ def evaluate_eligibility(self) -> eligibility.EligibilityStatus:
130131
condition_results[condition_name] = best_candidate
131132

132133
# Consolidate all the results and return
133-
final_result = [
134-
Condition(
135-
condition_name=condition_name,
136-
status=active_iteration_result.status,
137-
cohort_results=active_iteration_result.cohort_results,
138-
)
139-
for condition_name, active_iteration_result in condition_results.items()
140-
]
134+
final_result = self.build_condition_results(condition_results)
141135
return eligibility.EligibilityStatus(conditions=final_result)
142136

137+
def get_cohort_results(self, active_iteration: rules.Iteration) -> dict[str, CohortGroupResult]:
138+
cohort_results: dict[str, CohortGroupResult] = {}
139+
filter_rules, suppression_rules = self.get_rules_by_type(active_iteration)
140+
for cohort in sorted(active_iteration.iteration_cohorts, key=attrgetter("priority")):
141+
# Base Eligibility - check
142+
if cohort.cohort_label in self.person_cohorts or cohort.is_magic_cohort:
143+
# Eligibility - check
144+
if self.is_eligible_by_filter_rules(cohort, cohort_results, filter_rules):
145+
# Actionability - evaluation
146+
self.evaluate_suppression_rules(cohort, cohort_results, suppression_rules)
147+
148+
# Not base eligible
149+
elif cohort.cohort_label is not None:
150+
cohort_results[cohort.cohort_label] = CohortGroupResult(
151+
(cohort.cohort_group),
152+
Status.not_eligible,
153+
[],
154+
cohort.negative_description,
155+
)
156+
return cohort_results
157+
158+
@staticmethod
159+
def build_condition_results(
160+
condition_results: dict[ConditionName, IterationResult],
161+
) -> list[Condition]:
162+
conditions: list[Condition] = []
163+
# iterate over conditions
164+
for condition_name, active_iteration_result in condition_results.items():
165+
grouped_cohort_results = defaultdict(list)
166+
# iterate over cohorts and group them by status and cohort_group
167+
for cohort_result in active_iteration_result.cohort_results:
168+
if active_iteration_result.status == cohort_result.status:
169+
grouped_cohort_results[cohort_result.cohort_code].append(cohort_result)
170+
171+
# deduplicate grouped cohort results by cohort_code
172+
deduplicated_cohort_results = [
173+
CohortGroupResult(
174+
cohort_code=group_cohort_code,
175+
status=group[0].status,
176+
# Flatten all reasons from the group
177+
reasons=[reason for cohort in group for reason in cohort.reasons],
178+
# get the first nonempty description
179+
description=next((c.description for c in group if c.description), group[0].description),
180+
)
181+
for group_cohort_code, group in grouped_cohort_results.items()
182+
if group
183+
]
184+
185+
# return condition with cohort results
186+
conditions.append(
187+
Condition(
188+
condition_name=condition_name,
189+
status=active_iteration_result.status,
190+
cohort_results=list(deduplicated_cohort_results),
191+
)
192+
)
193+
return conditions
194+
143195
def is_eligible_by_filter_rules(
144196
self,
145197
cohort: IterationCohort,
146-
cohort_results: dict[str, CohortResult],
198+
cohort_results: dict[str, CohortGroupResult],
147199
filter_rules: Iterable[rules.IterationRule],
148200
) -> bool:
149201
is_eligible = True
@@ -156,11 +208,11 @@ def is_eligible_by_filter_rules(
156208
)
157209
if status.is_exclusion:
158210
if cohort.cohort_label is not None:
159-
cohort_results[str(cohort.cohort_label)] = CohortResult(
160-
cohort.cohort_group if cohort.cohort_group else cohort.cohort_label,
211+
cohort_results[cohort.cohort_label] = CohortGroupResult(
212+
(cohort.cohort_group),
161213
Status.not_eligible,
162214
[],
163-
str(cohort.negative_description),
215+
cohort.negative_description,
164216
)
165217
is_eligible = False
166218
break
@@ -169,7 +221,7 @@ def is_eligible_by_filter_rules(
169221
def evaluate_suppression_rules(
170222
self,
171223
cohort: IterationCohort,
172-
cohort_results: dict[str, CohortResult],
224+
cohort_results: dict[str, CohortGroupResult],
173225
suppression_rules: Iterable[rules.IterationRule],
174226
) -> None:
175227
is_actionable: bool = True
@@ -191,18 +243,18 @@ def evaluate_suppression_rules(
191243
if cohort.cohort_label is not None:
192244
key = cohort.cohort_label
193245
if is_actionable:
194-
cohort_results[key] = CohortResult(
195-
cohort.cohort_group if cohort.cohort_group else key,
246+
cohort_results[key] = CohortGroupResult(
247+
cohort.cohort_group,
196248
Status.actionable,
197249
[],
198-
str(cohort.positive_description),
250+
cohort.positive_description,
199251
)
200252
else:
201-
cohort_results[key] = CohortResult(
202-
cohort.cohort_group if cohort.cohort_group else key,
253+
cohort_results[key] = CohortGroupResult(
254+
cohort.cohort_group,
203255
Status.not_actionable,
204256
suppression_reasons,
205-
str(cohort.positive_description),
257+
cohort.positive_description,
206258
)
207259

208260
def evaluate_rules_priority_group(

0 commit comments

Comments
 (0)