Skip to content

Commit bd392a1

Browse files
Karthikeyannhsdependabot[bot]oneeb-nhsrobbailiff2
authored
Eli 615 : fix - multi campaign target collision (#593)
* ELI-615 | campaign having recent - active start_date supersedes the others sharing same best-status * ELI-615 | more linting * ELI-615 | revert commit * ELI-615 | wip * ELI-615 | wip * ELI-615 | wip * Bump werkzeug from 3.1.5 to 3.1.6 Bumps [werkzeug](https://github.com/pallets/werkzeug) from 3.1.5 to 3.1.6. - [Release notes](https://github.com/pallets/werkzeug/releases) - [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst) - [Commits](pallets/werkzeug@3.1.5...3.1.6) --- updated-dependencies: - dependency-name: werkzeug dependency-version: 3.1.6 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> * Updated not_member_of operator to NotMemberOf (#594) * Added vulture to workflows (#585) * Added vulture to workflows * Added new make commands and added to project * Added updated lockfile * Minimal config with no errors * Corrected vulture commands * Generating new lock file * ELI-615 | modified iterations_result to iteration result * ELI-615 | fix - naming issues | handle stop iter exception * ELI-615 | campaign_configs - fixture updated | test case fixed * ELI-615 | fix flaky tests do to fixture scope * ELI-615 | fix flaky tests - removed best status test * ELI-615 | used raw campagin config for tests using iteration dates * ELI-615 | fix - campaign group is used correctly * ELI-615 | fix test_campaigns_grouped_by_condition_name_filters_correctly * ELI-615 | fix tests * ELI-615 | linting * ELI-615 | renamed best_iteration_result to iteration_result_summary * ELI-615 | add more test cases - it tests * ELI-615 | test commit - try git leaks ignore * ELI-615 | incorporated review comments --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: oneeb-nhs <258801025+oneeb-nhs@users.noreply.github.com> Co-authored-by: Robert Bailiff <rob.bailiff1@nhs.net>
1 parent c4ed1c1 commit bd392a1

10 files changed

Lines changed: 398 additions & 176 deletions

File tree

.gitleaksignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
# SEE: https://github.com/gitleaks/gitleaks/blob/master/README.md#gitleaksignore
22

33
cd9c0efec38c5d63053dd865e5d4e207c0760d91:docs/guides/Perform_static_analysis.md:generic-api-key:37
4+
5+
bf0c77098978c450d8570b38fb480fbb8d6a0628:.github/instructions/*.instructions.md:stripe-access-token:140

src/eligibility_signposting_api/audit/audit_context.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@
1919
)
2020
from eligibility_signposting_api.audit.audit_service import AuditService
2121
from eligibility_signposting_api.model.eligibility_status import (
22-
BestIterationResult,
2322
CohortGroupResult,
2423
ConditionName,
2524
IterationResult,
25+
IterationResultSummary,
2626
MatchedActionDetail,
2727
Reason,
2828
RuleType,
@@ -63,13 +63,13 @@ def add_request_details(request: Request) -> None:
6363
@staticmethod
6464
def append_audit_condition(
6565
condition_name: ConditionName,
66-
best_iteration_result: BestIterationResult,
66+
iteration_result_summary: IterationResultSummary,
6767
action_detail: MatchedActionDetail,
6868
) -> None:
6969
audit_eligibility_cohorts, audit_eligibility_cohort_groups, audit_actions = [], [], []
70-
best_active_iteration = best_iteration_result.active_iteration
71-
best_candidate = best_iteration_result.iteration_result
72-
best_cohort_results = best_iteration_result.cohort_results
70+
best_active_iteration = iteration_result_summary.active_iteration
71+
best_candidate = iteration_result_summary.iteration_result
72+
best_cohort_results = iteration_result_summary.cohort_results
7373
filter_audit_rules, suitability_audit_rules = [], []
7474

7575
if best_cohort_results:
@@ -94,8 +94,8 @@ def append_audit_condition(
9494
audit_actions = AuditContext.create_audit_actions(action_detail.actions)
9595

9696
audit_condition = AuditCondition(
97-
campaign_id=best_iteration_result.campaign_id,
98-
campaign_version=best_iteration_result.campaign_version,
97+
campaign_id=iteration_result_summary.campaign_id,
98+
campaign_version=iteration_result_summary.campaign_version,
9999
iteration_id=best_active_iteration.id if best_active_iteration else None,
100100
iteration_version=best_active_iteration.version if best_active_iteration else None,
101101
condition_name=condition_name,

src/eligibility_signposting_api/model/eligibility_status.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ class IterationResult:
138138

139139

140140
@dataclass
141-
class BestIterationResult:
141+
class IterationResultSummary:
142142
iteration_result: IterationResult
143143
active_iteration: Iteration | None = None
144144
campaign_id: CampaignID | None = None

src/eligibility_signposting_api/services/calculators/eligibility_calculator.py

Lines changed: 33 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@
1111
from eligibility_signposting_api.audit.audit_context import AuditContext
1212
from eligibility_signposting_api.model import campaign_config, eligibility_status
1313
from eligibility_signposting_api.model.eligibility_status import (
14-
BestIterationResult,
1514
CohortGroupResult,
1615
Condition,
1716
ConditionName,
1817
EligibilityStatus,
1918
IterationResult,
19+
IterationResultSummary,
2020
Reason,
2121
Status,
2222
StatusText,
@@ -32,7 +32,6 @@
3232
from eligibility_signposting_api.model.campaign_config import (
3333
CampaignConfig,
3434
CohortLabel,
35-
IterationName,
3635
)
3736
from eligibility_signposting_api.model.person import Person
3837

@@ -81,31 +80,32 @@ def get_the_best_cohort_memberships(
8180

8281
return best_status, best_cohorts
8382

84-
def get_eligibility_status(self, include_actions: str, conditions: list[str], category: str) -> EligibilityStatus:
83+
def get_eligibility_status(
84+
self, include_actions: str, conditions: list[str], requested_category: str
85+
) -> EligibilityStatus:
8586
include_actions_flag = include_actions.upper() == "Y"
8687
condition_results: dict[ConditionName, IterationResult] = {}
8788
final_result = []
8889

89-
requested_grouped_campaigns = self.campaign_evaluator.get_requested_grouped_campaigns(
90-
self.campaign_configs, conditions, category
90+
requested_cc_with_active_iteration = (
91+
self.campaign_evaluator.get_campaign_with_latest_active_iteration_per_target(
92+
self.campaign_configs, conditions, requested_category
93+
)
9194
)
92-
for condition_name, campaign_group in requested_grouped_campaigns:
93-
best_iteration_result = self.get_best_iteration_result(campaign_group)
94-
95-
if best_iteration_result is None:
96-
continue
95+
for condition_name, campaign in requested_cc_with_active_iteration:
96+
iteration_result_summary = self.evaluate_iteration_result_summary(campaign)
9797

9898
matched_action_detail = self.action_rule_handler.get_actions(
9999
self.person,
100-
best_iteration_result.active_iteration,
101-
best_iteration_result.iteration_result,
100+
iteration_result_summary.active_iteration,
101+
iteration_result_summary.iteration_result,
102102
include_actions_flag=include_actions_flag,
103103
)
104104

105-
best_iteration_result = TokenProcessor.find_and_replace_tokens(self.person, best_iteration_result)
105+
iteration_result_summary = TokenProcessor.find_and_replace_tokens(self.person, iteration_result_summary)
106106
matched_action_detail = TokenProcessor.find_and_replace_tokens(self.person, matched_action_detail)
107107

108-
condition_results[condition_name] = best_iteration_result.iteration_result
108+
condition_results[condition_name] = iteration_result_summary.iteration_result
109109
condition_results[condition_name].actions = matched_action_detail.actions
110110

111111
condition: Condition = self.build_condition(
@@ -116,54 +116,34 @@ def get_eligibility_status(self, include_actions: str, conditions: list[str], ca
116116

117117
AuditContext.append_audit_condition(
118118
condition_name,
119-
best_iteration_result,
119+
iteration_result_summary,
120120
matched_action_detail,
121121
)
122122

123123
# Consolidate all the results and return
124124
return eligibility_status.EligibilityStatus(conditions=final_result)
125125

126-
def get_best_iteration_result(self, campaign_group: list[CampaignConfig]) -> BestIterationResult | None:
127-
iteration_results = self.get_iteration_results(campaign_group)
128-
129-
if not iteration_results:
130-
return None
131-
132-
(_best_iteration_name, best_iteration_result) = max(
133-
iteration_results.items(),
134-
key=lambda item: next(iter(item[1].cohort_results.values())).status.value
135-
# Below handles the case where there are no cohort results
136-
if item[1].cohort_results
137-
else -1,
126+
def evaluate_iteration_result_summary(
127+
self, campaign_with_active_iteration: CampaignConfig
128+
) -> IterationResultSummary:
129+
active_iteration = campaign_with_active_iteration.current_iteration
130+
cohort_results: dict[CohortLabel, CohortGroupResult] = self.rule_processor.get_cohort_group_results(
131+
self.person, active_iteration
138132
)
139133

140-
return best_iteration_result
141-
142-
def get_iteration_results(self, campaign_group: list[CampaignConfig]) -> dict[IterationName, BestIterationResult]:
143-
iteration_results: dict[IterationName, BestIterationResult] = {}
144-
145-
for cc in campaign_group:
146-
try:
147-
active_iteration = cc.current_iteration
148-
except StopIteration:
149-
logger.info("Skipping campaign ID %s as no active iteration was found.", cc.id)
150-
continue
151-
cohort_results: dict[CohortLabel, CohortGroupResult] = self.rule_processor.get_cohort_group_results(
152-
self.person, active_iteration
153-
)
154-
155-
# Determine Result between cohorts - get the best
156-
status, best_cohorts = self.get_the_best_cohort_memberships(cohort_results)
157-
status_text = self.get_status_text(active_iteration.status_text, ConditionName(cc.target), status)
134+
# Determine Result between cohorts - get the best
135+
status, best_cohorts = self.get_the_best_cohort_memberships(cohort_results)
136+
status_text = self.get_status_text(
137+
active_iteration.status_text, ConditionName(campaign_with_active_iteration.target), status
138+
)
158139

159-
iteration_results[active_iteration.name] = BestIterationResult(
160-
IterationResult(status, status_text, best_cohorts, []),
161-
active_iteration,
162-
cc.id,
163-
cc.version,
164-
cohort_results,
165-
)
166-
return iteration_results
140+
return IterationResultSummary(
141+
IterationResult(status, status_text, best_cohorts, []),
142+
active_iteration,
143+
campaign_with_active_iteration.id,
144+
campaign_with_active_iteration.version,
145+
cohort_results,
146+
)
167147

168148
@staticmethod
169149
def get_status_text(

src/eligibility_signposting_api/services/processors/action_rule_handler.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,14 @@ def get_actions(
2727
self,
2828
person: Person,
2929
active_iteration: Iteration | None,
30-
best_iteration_result: IterationResult,
30+
iteration_result: IterationResult,
3131
*,
3232
include_actions_flag: bool,
3333
) -> MatchedActionDetail:
3434
action_detail = MatchedActionDetail()
3535

3636
if active_iteration is not None and include_actions_flag:
37-
rule_type = best_iteration_result.status.get_action_rule_type()
37+
rule_type = iteration_result.status.get_action_rule_type()
3838
action_detail = self._handle(person, active_iteration, rule_type)
3939

4040
return action_detail
Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from collections.abc import Collection, Iterator
23
from itertools import groupby
34
from operator import attrgetter
@@ -7,6 +8,8 @@
78
from eligibility_signposting_api.model import eligibility_status
89
from eligibility_signposting_api.model.campaign_config import CampaignConfig
910

11+
logger = logging.getLogger(__name__)
12+
1013

1114
@service
1215
class CampaignEvaluator:
@@ -15,29 +18,68 @@ class CampaignEvaluator:
1518
def get_active_campaigns(self, campaign_configs: Collection[CampaignConfig]) -> list[CampaignConfig]:
1619
return [cc for cc in campaign_configs if cc.campaign_live]
1720

18-
def get_requested_grouped_campaigns(
19-
self, campaign_configs: Collection[CampaignConfig], conditions: list[str], category: str
20-
) -> Iterator[tuple[eligibility_status.ConditionName, list[CampaignConfig]]]:
21+
def get_campaign_with_latest_iteration(self, active_campaigns: list[CampaignConfig]) -> CampaignConfig | None:
22+
"""
23+
Returns the campaign with the latest active iteration date.
24+
25+
1. Collect all campaigns with an active iteration.
26+
2. Sort by iteration date (descending).
27+
3. Extract the lead campaign, throwing an error if a tie for the latest date exists.
28+
"""
29+
30+
valid_items = []
31+
32+
for cc in active_campaigns:
33+
try:
34+
valid_items.append((cc.current_iteration.iteration_date, cc))
35+
except StopIteration:
36+
logger.info(
37+
"Skipping campaign ID %s as no active iteration was found.",
38+
cc.id,
39+
)
40+
41+
if not valid_items:
42+
latest_campaign = None
43+
else:
44+
max_date = max(item[0] for item in valid_items)
45+
cc_with_max_iteration_date: list[CampaignConfig] = [item[1] for item in valid_items if item[0] == max_date]
46+
if len(cc_with_max_iteration_date) > 1:
47+
err_msg = (
48+
f"Ambiguous result: '{len(cc_with_max_iteration_date)}' active iterations "
49+
f"for target {cc_with_max_iteration_date[0].target} "
50+
f"found for date '{max_date}' "
51+
f"across campaign(s) {[cc.id for cc in cc_with_max_iteration_date]}"
52+
)
53+
raise ValueError(err_msg)
54+
55+
latest_campaign = cc_with_max_iteration_date[0]
56+
57+
return latest_campaign
58+
59+
def get_campaign_with_latest_active_iteration_per_target(
60+
self, campaign_configs: Collection[CampaignConfig], conditions: list[str], requested_category: str
61+
) -> Iterator[tuple[eligibility_status.ConditionName, CampaignConfig]]:
2162
mapping = {
2263
"ALL": {"V", "S"},
2364
"VACCINATIONS": {"V"},
2465
"SCREENING": {"S"},
2566
}
2667

27-
allowed_types = mapping.get(category, set())
68+
allowed_types = mapping.get(requested_category, set())
2869

2970
filter_all_conditions = "ALL" in conditions
3071

31-
active_campaigns = self.get_active_campaigns(campaign_configs)
72+
allowed_campaigns = [c for c in campaign_configs if c.type in allowed_types]
73+
active_campaigns = self.get_active_campaigns(allowed_campaigns)
3274

3375
for condition_name, campaign_group in groupby(
3476
sorted(active_campaigns, key=attrgetter("target")),
3577
key=attrgetter("target"),
3678
):
37-
campaigns = list(campaign_group)
38-
if (
39-
campaigns
40-
and campaigns[0].type in allowed_types
41-
and (filter_all_conditions or str(condition_name) in conditions)
42-
):
43-
yield condition_name, campaigns
79+
filtered_campaigns = [
80+
c for c in campaign_group if filter_all_conditions or str(condition_name) in conditions
81+
]
82+
83+
campaign = self.get_campaign_with_latest_iteration(filtered_campaigns)
84+
if campaign is not None:
85+
yield (condition_name, campaign)

0 commit comments

Comments
 (0)