Skip to content

Commit fac3739

Browse files
ayeshalshukri1-nhsshweta-nhsKarthikeyannhs
authored
Feature/eli 138 audit record within lambda (#214)
* Patch - Initial test and AuditService stub. * Patch - Commit 1 Adds flask g and before and after request audit * Patch - Added audit record logic. * Patch - Fix broken tests. * Adding integration test for audit * Fixed actions part of the audit * Added unit tests for AuditContext * Added unit tests and fixed TODOs * Fixed TODOs * Fixed linting * Uses list[SuggestedAction] instead of SuggestedActions * Adds internal action code to audit actions * Fix todo's * Fix linting again * Fixing priority type * Cleaning audit bucket after use per every test run * Fix lint * Fix format and lint * Convert audit_log into camelCase when writing record to firehose * github role permissions - reduced (#217) * Adds audit package for separation of concerns --------- Co-authored-by: Shweta <[email protected]> Co-authored-by: Karthikeyannhs <[email protected]>
1 parent 8e4f73e commit fac3739

File tree

18 files changed

+1035
-69
lines changed

18 files changed

+1035
-69
lines changed

src/eligibility_signposting_api/app.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from mangum import Mangum
88
from mangum.types import LambdaContext, LambdaEvent
99

10-
from eligibility_signposting_api import repos, services
10+
from eligibility_signposting_api import audit, repos, services
1111
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
@@ -41,7 +41,9 @@ def create_app() -> Flask:
4141
app.register_error_handler(Exception, handle_exception)
4242

4343
# Set up dependency injection using wireup
44-
container = wireup.create_sync_container(service_modules=[services, repos], parameters={**app.config, **config()})
44+
container = wireup.create_sync_container(
45+
service_modules=[services, repos, audit], parameters={**app.config, **config()}
46+
)
4547
wireup.integration.flask.setup(container, app)
4648

4749
logger.info("app ready", extra={"config": {**app.config, **config()}})

src/eligibility_signposting_api/audit/__init__.py

Whitespace-only changes.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import logging
2+
from datetime import UTC, datetime
3+
from operator import attrgetter
4+
from uuid import UUID
5+
6+
from flask import Request, g
7+
8+
from eligibility_signposting_api.audit.audit_models import (
9+
AuditAction,
10+
AuditCondition,
11+
AuditEligibilityCohortGroups,
12+
AuditEligibilityCohorts,
13+
AuditEvent,
14+
AuditFilterRule,
15+
AuditRedirectRule,
16+
AuditSuitabilityRule,
17+
RequestAuditData,
18+
RequestAuditHeader,
19+
RequestAuditQueryParams,
20+
)
21+
from eligibility_signposting_api.audit.audit_service import AuditService
22+
from eligibility_signposting_api.model.eligibility import (
23+
CohortGroupResult,
24+
ConditionName,
25+
IterationResult,
26+
Status,
27+
SuggestedAction,
28+
)
29+
from eligibility_signposting_api.model.rules import CampaignID, CampaignVersion, Iteration, RuleName, RulePriority
30+
31+
logger = logging.getLogger(__name__)
32+
33+
34+
class AuditContext:
35+
@staticmethod
36+
def add_request_details(request: Request) -> None:
37+
g.audit_log = AuditEvent()
38+
resource_id = None
39+
if request.view_args and request.view_args["nhs_number"]:
40+
resource_id = request.view_args["nhs_number"]
41+
g.audit_log.request = RequestAuditData(
42+
nhs_number=resource_id,
43+
request_timestamp=datetime.now(tz=UTC),
44+
headers=(
45+
RequestAuditHeader(
46+
x_request_id=request.headers.get("X-Request-ID"),
47+
x_correlation_id=request.headers.get("X-Correlation-ID"),
48+
nhsd_end_user_organisation_ods=request.headers.get("NHSD-End-User-Organisation-ODS"),
49+
nhsd_application_id=request.headers.get("nhsd-application-id"),
50+
)
51+
),
52+
query_params=(
53+
RequestAuditQueryParams(
54+
category=request.args.get("category"),
55+
conditions=request.args.get("conditions"),
56+
include_actions=request.args.get("includeActions"),
57+
)
58+
),
59+
)
60+
61+
@staticmethod
62+
def append_audit_condition(
63+
suggested_actions: list[SuggestedAction] | None,
64+
condition_name: ConditionName,
65+
best_results: tuple[Iteration | None, IterationResult | None, dict[str, CohortGroupResult] | None],
66+
campaign_details: tuple[CampaignID | None, CampaignVersion | None],
67+
redirect_rule_details: tuple[RulePriority | None, RuleName | None],
68+
) -> None:
69+
audit_eligibility_cohorts, audit_eligibility_cohort_groups, audit_actions = [], [], []
70+
audit_filter_rule, audit_suitability_rule, audit_redirect_rule = None, None, None
71+
best_active_iteration = best_results[0]
72+
best_candidate = best_results[1]
73+
best_cohort_results = best_results[2]
74+
75+
if best_cohort_results:
76+
for value in sorted(best_cohort_results.values(), key=attrgetter("cohort_code")):
77+
cohort_status_name = value.status.name if value.status else None
78+
audit_eligibility_cohorts.append(
79+
AuditEligibilityCohorts(cohort_code=value.cohort_code, cohort_status=cohort_status_name)
80+
)
81+
82+
audit_eligibility_cohort_groups.append(
83+
AuditEligibilityCohortGroups(
84+
cohort_code=value.cohort_code, cohort_status=cohort_status_name, cohort_text=value.description
85+
)
86+
)
87+
88+
if value.audit_rules and best_candidate:
89+
if best_candidate.status and best_candidate.status.name == Status.not_eligible.name:
90+
audit_filter_rule = AuditFilterRule(
91+
rule_priority=value.audit_rules[0].rule_priority,
92+
rule_name=value.audit_rules[0].rule_name,
93+
)
94+
if best_candidate.status and best_candidate.status.name == Status.not_actionable.name:
95+
audit_suitability_rule = AuditSuitabilityRule(
96+
rule_priority=value.audit_rules[0].rule_priority,
97+
rule_name=value.audit_rules[0].rule_name,
98+
rule_message=value.audit_rules[0].rule_description,
99+
)
100+
101+
if best_candidate and best_candidate.status and best_candidate.status.name == Status.actionable.name:
102+
audit_redirect_rule = AuditRedirectRule(
103+
rule_priority=str(redirect_rule_details[0]), rule_name=redirect_rule_details[1]
104+
)
105+
106+
if suggested_actions is None:
107+
audit_actions = None
108+
elif len(suggested_actions) > 0:
109+
for action in suggested_actions:
110+
audit_actions.append(
111+
AuditAction(
112+
internal_action_code=action.internal_action_code,
113+
action_code=action.action_code,
114+
action_type=action.action_type,
115+
action_description=action.action_description,
116+
action_url=str(action.url_link) if action.url_link else None,
117+
action_url_label=action.url_label,
118+
)
119+
)
120+
121+
audit_condition = AuditCondition(
122+
campaign_id=campaign_details[0],
123+
campaign_version=campaign_details[1],
124+
iteration_id=best_active_iteration.id if best_active_iteration else None,
125+
iteration_version=best_active_iteration.version if best_active_iteration else None,
126+
condition_name=condition_name,
127+
status=best_candidate.status.name if best_candidate and best_candidate.status else None,
128+
status_text=best_candidate.status.name if best_candidate and best_candidate.status else None,
129+
eligibility_cohorts=audit_eligibility_cohorts,
130+
eligibility_cohort_groups=audit_eligibility_cohort_groups,
131+
filter_rules=audit_filter_rule,
132+
suitability_rules=audit_suitability_rule,
133+
action_rule=audit_redirect_rule,
134+
actions=audit_actions,
135+
)
136+
137+
g.audit_log.response.condition.append(audit_condition)
138+
139+
@staticmethod
140+
def add_response_details(response_id: UUID, last_updated: datetime) -> None:
141+
g.audit_log.response.response_id = response_id
142+
g.audit_log.response.last_updated = last_updated
143+
144+
@staticmethod
145+
def write_to_firehose(service: AuditService) -> None:
146+
service.audit(g.audit_log.model_dump(by_alias=True))
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from datetime import UTC, datetime
2+
from uuid import UUID
3+
4+
from pydantic import BaseModel, ConfigDict, Field
5+
from pydantic.alias_generators import to_camel
6+
7+
8+
class CamelCaseBaseModel(BaseModel):
9+
model_config = ConfigDict(
10+
alias_generator=to_camel,
11+
populate_by_name=True,
12+
)
13+
14+
15+
class RequestAuditHeader(CamelCaseBaseModel):
16+
x_request_id: str | None = None
17+
x_correlation_id: str | None = None
18+
nhsd_end_user_organisation_ods: str | None = None
19+
nhsd_application_id: str | None = None
20+
21+
22+
class RequestAuditQueryParams(CamelCaseBaseModel):
23+
category: str | None = None
24+
conditions: str | None = None
25+
include_actions: str | None = None
26+
27+
28+
class RequestAuditData(CamelCaseBaseModel):
29+
request_timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
30+
headers: RequestAuditHeader = Field(default_factory=RequestAuditHeader)
31+
query_params: RequestAuditQueryParams = Field(default_factory=RequestAuditQueryParams)
32+
nhs_number: str | None = None
33+
34+
35+
class AuditEligibilityCohorts(CamelCaseBaseModel):
36+
cohort_code: str | None = None
37+
cohort_status: str | None = None
38+
39+
40+
class AuditEligibilityCohortGroups(CamelCaseBaseModel):
41+
cohort_code: str | None = None
42+
cohort_text: str | None = None
43+
cohort_status: str | None = None
44+
45+
46+
class AuditFilterRule(CamelCaseBaseModel):
47+
rule_priority: str | None = None
48+
rule_name: str | None = None
49+
50+
51+
class AuditSuitabilityRule(CamelCaseBaseModel):
52+
rule_priority: str | None = None
53+
rule_name: str | None = None
54+
rule_message: str | None = None
55+
56+
57+
class AuditRedirectRule(CamelCaseBaseModel):
58+
rule_priority: str | None = None
59+
rule_name: str | None = None
60+
61+
62+
class AuditAction(CamelCaseBaseModel):
63+
internal_action_code: str | None = None
64+
action_type: str | None = None
65+
action_code: str | None = None
66+
action_description: str | None = None
67+
action_url: str | None = None
68+
action_url_label: str | None = None
69+
70+
71+
class AuditCondition(CamelCaseBaseModel):
72+
campaign_id: str | None = None
73+
campaign_version: str | None = None
74+
iteration_id: str | None = None
75+
iteration_version: str | None = None
76+
condition_name: str | None = None
77+
status: str | None = None
78+
status_text: str | None = None
79+
eligibility_cohorts: list[AuditEligibilityCohorts] | None = None
80+
eligibility_cohort_groups: list[AuditEligibilityCohortGroups] | None = None
81+
filter_rules: AuditFilterRule | None = None
82+
suitability_rules: AuditSuitabilityRule | None = None
83+
action_rule: AuditRedirectRule | None = None
84+
actions: list[AuditAction] | None = Field(default_factory=list)
85+
86+
87+
class ResponseAuditData(CamelCaseBaseModel):
88+
response_id: UUID | None = None
89+
last_updated: str | None = None
90+
condition: list[AuditCondition] = Field(default_factory=list)
91+
92+
93+
class AuditEvent(CamelCaseBaseModel):
94+
request: RequestAuditData = Field(default_factory=RequestAuditData)
95+
response: ResponseAuditData = Field(default_factory=ResponseAuditData)

src/eligibility_signposting_api/services/audit_service.py renamed to src/eligibility_signposting_api/audit/audit_service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ def audit(self, audit_record: dict) -> None:
3131
Returns:
3232
str: The Firehose record ID.
3333
"""
34+
data = json.dumps(audit_record, default=str)
3435
response = self.firehose.put_record(
3536
DeliveryStreamName=self.audit_delivery_stream,
36-
Record={"Data": (json.dumps(audit_record) + "\n").encode("utf-8")},
37+
Record={"Data": (data + "\n").encode("utf-8")},
3738
)
3839
logger.info("Successfully sent to the Firehose", extra={"firehose_record_id": response["RecordId"]})

src/eligibility_signposting_api/model/eligibility.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515

1616
RuleName = NewType("RuleName", str)
1717
RuleDescription = NewType("RuleDescription", str)
18+
RulePriority = NewType("RulePriority", str)
1819

20+
InternalActionCode = NewType("InternalActionCode", str)
1921
ActionType = NewType("ActionType", str)
2022
ActionCode = NewType("ActionCode", str)
2123
ActionDescription = NewType("ActionDescription", str)
@@ -68,6 +70,7 @@ def best(*statuses: Status) -> Status:
6870
class Reason:
6971
rule_type: RuleType
7072
rule_name: RuleName
73+
rule_priority: RulePriority
7174
rule_description: RuleDescription | None
7275
matcher_matched: bool
7376

@@ -79,6 +82,7 @@ class SuggestedAction:
7982
action_description: ActionDescription | None
8083
url_link: UrlLink | None
8184
url_label: UrlLabel | None
85+
internal_action_code: InternalActionCode | None = None
8286

8387

8488
@dataclass
@@ -95,6 +99,7 @@ class CohortGroupResult:
9599
status: Status
96100
reasons: list[Reason]
97101
description: str | None
102+
audit_rules: list[Reason]
98103

99104

100105
@dataclass

0 commit comments

Comments
 (0)