Skip to content

Commit 8c90c58

Browse files
authored
Merge pull request #515 from NHSDigital/feature/eja-eli-579-adding-derivation-for-previous-dose-calculations
Feature/eja eli 579 adding derivation for previous dose calculations
2 parents dc483f3 + 03cea5b commit 8c90c58

15 files changed

Lines changed: 1856 additions & 18 deletions

File tree

poetry.lock

Lines changed: 0 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from eligibility_signposting_api.services.processors.derived_values.add_days_handler import AddDaysHandler
2+
from eligibility_signposting_api.services.processors.derived_values.base import (
3+
DerivedValueContext,
4+
DerivedValueHandler,
5+
)
6+
from eligibility_signposting_api.services.processors.derived_values.registry import (
7+
DerivedValueRegistry,
8+
get_registry,
9+
)
10+
11+
__all__ = [
12+
"AddDaysHandler",
13+
"DerivedValueContext",
14+
"DerivedValueHandler",
15+
"DerivedValueRegistry",
16+
"get_registry",
17+
]
18+
19+
# Register default handlers
20+
DerivedValueRegistry.register_default(
21+
AddDaysHandler(
22+
default_days=91,
23+
vaccine_type_days={
24+
"COVID": 91, # 91 days between COVID vaccinations
25+
# Add other vaccine-specific configurations here as needed.
26+
},
27+
)
28+
)
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
from datetime import UTC, datetime, timedelta
2+
from typing import ClassVar
3+
4+
from eligibility_signposting_api.services.processors.derived_values.base import (
5+
DerivedValueContext,
6+
DerivedValueHandler,
7+
)
8+
9+
10+
class AddDaysHandler(DerivedValueHandler):
11+
"""Handler for adding days to a date value.
12+
13+
This handler calculates derived dates by adding a configurable number of days
14+
to a source date attribute. It supports:
15+
- Default days value for all vaccine types
16+
- Vaccine-specific days configuration
17+
- Configurable mapping of derived attributes to source attributes
18+
19+
Example token: [[TARGET.COVID.NEXT_DOSE_DUE:ADD_DAYS(91)]]
20+
This would add 91 days to COVID's LAST_SUCCESSFUL_DATE to calculate NEXT_DOSE_DUE.
21+
22+
The number of days can be specified in three ways (in order of precedence):
23+
1. In the token itself: :ADD_DAYS(91)
24+
2. In the vaccine_type_days configuration
25+
3. Using the default_days value
26+
"""
27+
28+
function_name: str = "ADD_DAYS"
29+
30+
# Mapping of derived attribute names to their source attributes
31+
DERIVED_ATTRIBUTE_SOURCES: ClassVar[dict[str, str]] = {
32+
"NEXT_DOSE_DUE": "LAST_SUCCESSFUL_DATE",
33+
}
34+
35+
def __init__(
36+
self,
37+
default_days: int = 91,
38+
vaccine_type_days: dict[str, int] | None = None,
39+
) -> None:
40+
"""Initialize the AddDaysHandler.
41+
42+
Args:
43+
default_days: Default number of days to add when not specified
44+
in token or vaccine_type_days. Defaults to 91.
45+
vaccine_type_days: Dictionary mapping vaccine types to their
46+
specific days values. E.g., {"COVID": 91, "FLU": 365}
47+
"""
48+
self.default_days = default_days
49+
self.vaccine_type_days = vaccine_type_days or {}
50+
51+
def get_source_attribute(self, target_attribute: str, function_args: str | None = None) -> str:
52+
"""Get the source attribute for a derived attribute.
53+
54+
Check if source is provided in function args (e.g., ADD_DAYS(91, SOURCE_FIELD)).
55+
If not, fall back to mapping or return target_attribute as default.
56+
57+
Args:
58+
target_attribute: The derived attribute name (e.g., 'NEXT_DOSE_DUE')
59+
function_args: Optional arguments from token (e.g., '91, LAST_SUCCESSFUL_DATE')
60+
61+
Returns:
62+
The source attribute name (e.g., 'LAST_SUCCESSFUL_DATE')
63+
"""
64+
if function_args and "," in function_args:
65+
# Extract source from args if present (second argument)
66+
parts = [p.strip() for p in function_args.split(",")]
67+
if len(parts) > 1 and parts[1]:
68+
return parts[1].upper()
69+
70+
return self.DERIVED_ATTRIBUTE_SOURCES.get(target_attribute, target_attribute)
71+
72+
def calculate(self, context: DerivedValueContext) -> str:
73+
"""Calculate a date with added days.
74+
75+
Args:
76+
context: DerivedValueContext containing:
77+
- person_data: List of attribute dictionaries
78+
- attribute_name: Vaccine type (e.g., 'COVID')
79+
- source_attribute: The source date attribute
80+
- function_args: Optional days override from token
81+
- date_format: Optional output date format
82+
83+
Returns:
84+
The calculated date as a formatted string
85+
86+
Raises:
87+
ValueError: If source date is not found or invalid
88+
"""
89+
source_date = self._find_source_date(context)
90+
if not source_date:
91+
return ""
92+
93+
days_to_add = self._get_days_to_add(context)
94+
calculated_date = self._add_days_to_date(source_date, days_to_add)
95+
96+
return self._format_date(calculated_date, context.date_format)
97+
98+
def _find_source_date(self, context: DerivedValueContext) -> str | None:
99+
"""Find the source date value from person data.
100+
101+
Args:
102+
context: The derived value context
103+
104+
Returns:
105+
The source date string or None if not found
106+
"""
107+
source_attr = context.source_attribute
108+
if not source_attr:
109+
return None
110+
111+
for attribute in context.person_data:
112+
if attribute.get("ATTRIBUTE_TYPE") == context.attribute_name:
113+
return attribute.get(source_attr)
114+
115+
return None
116+
117+
def _get_days_to_add(self, context: DerivedValueContext) -> int:
118+
"""Determine the number of days to add.
119+
120+
Priority:
121+
1. Function argument from token (e.g., :ADD_DAYS(91))
122+
2. Vaccine-specific configuration
123+
3. Default days
124+
125+
Args:
126+
context: The derived value context
127+
128+
Returns:
129+
Number of days to add
130+
"""
131+
# Priority 1: Token argument (if non-empty)
132+
if context.function_args:
133+
args = context.function_args.split(",")[0].strip()
134+
if args:
135+
try:
136+
return int(args)
137+
except ValueError as e:
138+
message = f"Invalid days argument '{args}' for ADD_DAYS function. Expected an integer."
139+
raise ValueError(message) from e
140+
141+
# Priority 2: Vaccine-specific configuration
142+
if context.attribute_name in self.vaccine_type_days:
143+
return self.vaccine_type_days[context.attribute_name]
144+
145+
# Priority 3: Default
146+
return self.default_days
147+
148+
def _add_days_to_date(self, date_str: str, days: int) -> datetime:
149+
"""Parse a date string and add days.
150+
151+
Args:
152+
date_str: Date in YYYYMMDD format
153+
days: Number of days to add
154+
155+
Returns:
156+
The calculated datetime
157+
158+
Raises:
159+
ValueError: If date format is invalid
160+
"""
161+
try:
162+
date_obj = datetime.strptime(date_str, "%Y%m%d").replace(tzinfo=UTC)
163+
return date_obj + timedelta(days=days)
164+
except ValueError as e:
165+
message = f"Invalid date format: {date_str}"
166+
raise ValueError(message) from e
167+
168+
def _format_date(self, date_obj: datetime, date_format: str | None) -> str:
169+
"""Format a datetime object.
170+
171+
Args:
172+
date_obj: The datetime to format
173+
date_format: Optional strftime format string
174+
175+
Returns:
176+
Formatted date string. If no format specified, returns YYYYMMDD.
177+
"""
178+
if date_format:
179+
return date_obj.strftime(date_format)
180+
return date_obj.strftime("%Y%m%d")
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from abc import ABC, abstractmethod
2+
from dataclasses import dataclass
3+
from typing import Any
4+
5+
6+
@dataclass
7+
class DerivedValueContext:
8+
"""Context object containing all data needed for derived value calculation.
9+
10+
Attributes:
11+
person_data: List of person attribute dictionaries
12+
attribute_name: The condition/vaccine type (e.g., 'COVID', 'RSV')
13+
source_attribute: The source attribute to derive from (e.g., 'LAST_SUCCESSFUL_DATE')
14+
function_args: Arguments passed to the function (e.g., number of days)
15+
date_format: Optional date format string for output formatting
16+
"""
17+
18+
person_data: list[dict[str, Any]]
19+
attribute_name: str
20+
source_attribute: str | None
21+
function_args: str | None
22+
date_format: str | None
23+
24+
25+
class DerivedValueHandler(ABC):
26+
"""Abstract base class for derived value handlers.
27+
28+
Derived value handlers compute values that don't exist directly in the data
29+
but are calculated from existing attributes. Each handler is responsible for
30+
a specific type of calculation (e.g., adding days to a date).
31+
32+
To create a new derived value handler:
33+
1. Subclass DerivedValueHandler
34+
2. Set the `function_name` class attribute to the token function name (e.g., 'ADD_DAYS')
35+
3. Implement the `calculate` method
36+
4. Register the handler with the DerivedValueRegistry
37+
"""
38+
39+
function_name: str = ""
40+
41+
@abstractmethod
42+
def calculate(self, context: DerivedValueContext) -> str:
43+
"""Calculate the derived value.
44+
45+
Args:
46+
context: DerivedValueContext containing all necessary data
47+
48+
Returns:
49+
The calculated value as a string
50+
51+
Raises:
52+
ValueError: If the calculation cannot be performed
53+
"""
54+
55+
@abstractmethod
56+
def get_source_attribute(self, target_attribute: str, function_args: str | None = None) -> str:
57+
"""Get the source attribute name needed for this derived value.
58+
59+
For example, NEXT_DOSE_DUE derives from LAST_SUCCESSFUL_DATE.
60+
61+
Args:
62+
target_attribute: The target derived attribute name (e.g., 'NEXT_DOSE_DUE')
63+
function_args: Optional arguments from the token function call
64+
65+
Returns:
66+
The source attribute name to use for calculation
67+
"""

0 commit comments

Comments
 (0)