Skip to content

Commit 9b4f10d

Browse files
committed
eli-579 adding an add_days derivation
1 parent 4e1ead4 commit 9b4f10d

3 files changed

Lines changed: 601 additions & 0 deletions

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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) -> str:
52+
"""Get the source attribute for a derived attribute.
53+
54+
Args:
55+
target_attribute: The derived attribute name (e.g., 'NEXT_DOSE_DUE')
56+
57+
Returns:
58+
The source attribute name (e.g., 'LAST_SUCCESSFUL_DATE')
59+
"""
60+
return self.DERIVED_ATTRIBUTE_SOURCES.get(target_attribute, target_attribute)
61+
62+
def calculate(self, context: DerivedValueContext) -> str:
63+
"""Calculate a date with added days.
64+
65+
Args:
66+
context: DerivedValueContext containing:
67+
- person_data: List of attribute dictionaries
68+
- attribute_name: Vaccine type (e.g., 'COVID')
69+
- source_attribute: The source date attribute
70+
- function_args: Optional days override from token
71+
- date_format: Optional output date format
72+
73+
Returns:
74+
The calculated date as a formatted string
75+
76+
Raises:
77+
ValueError: If source date is not found or invalid
78+
"""
79+
source_date = self._find_source_date(context)
80+
if not source_date:
81+
return ""
82+
83+
days_to_add = self._get_days_to_add(context)
84+
calculated_date = self._add_days_to_date(source_date, days_to_add)
85+
86+
return self._format_date(calculated_date, context.date_format)
87+
88+
def _find_source_date(self, context: DerivedValueContext) -> str | None:
89+
"""Find the source date value from person data.
90+
91+
Args:
92+
context: The derived value context
93+
94+
Returns:
95+
The source date string or None if not found
96+
"""
97+
source_attr = context.source_attribute
98+
if not source_attr:
99+
return None
100+
101+
for attribute in context.person_data:
102+
if attribute.get("ATTRIBUTE_TYPE") == context.attribute_name:
103+
return attribute.get(source_attr)
104+
105+
return None
106+
107+
def _get_days_to_add(self, context: DerivedValueContext) -> int:
108+
"""Determine the number of days to add.
109+
110+
Priority:
111+
1. Function argument from token (e.g., :ADD_DAYS(91))
112+
2. Vaccine-specific configuration
113+
3. Default days
114+
115+
Args:
116+
context: The derived value context
117+
118+
Returns:
119+
Number of days to add
120+
"""
121+
# Priority 1: Token argument
122+
if context.function_args:
123+
try:
124+
return int(context.function_args)
125+
except ValueError:
126+
pass
127+
128+
# Priority 2: Vaccine-specific configuration
129+
if context.attribute_name in self.vaccine_type_days:
130+
return self.vaccine_type_days[context.attribute_name]
131+
132+
# Priority 3: Default
133+
return self.default_days
134+
135+
def _add_days_to_date(self, date_str: str, days: int) -> datetime:
136+
"""Parse a date string and add days.
137+
138+
Args:
139+
date_str: Date in YYYYMMDD format
140+
days: Number of days to add
141+
142+
Returns:
143+
The calculated datetime
144+
145+
Raises:
146+
ValueError: If date format is invalid
147+
"""
148+
try:
149+
date_obj = datetime.strptime(date_str, "%Y%m%d").replace(tzinfo=UTC)
150+
return date_obj + timedelta(days=days)
151+
except ValueError as e:
152+
message = f"Invalid date format: {date_str}"
153+
raise ValueError(message) from e
154+
155+
def _format_date(self, date_obj: datetime, date_format: str | None) -> str:
156+
"""Format a datetime object.
157+
158+
Args:
159+
date_obj: The datetime to format
160+
date_format: Optional strftime format string
161+
162+
Returns:
163+
Formatted date string. If no format specified, returns YYYYMMDD.
164+
"""
165+
if date_format:
166+
return date_obj.strftime(date_format)
167+
return date_obj.strftime("%Y%m%d")
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
from typing import ClassVar
2+
3+
from eligibility_signposting_api.services.processors.derived_values.base import (
4+
DerivedValueContext,
5+
DerivedValueHandler,
6+
)
7+
8+
9+
class DerivedValueRegistry:
10+
"""Registry for derived value handlers.
11+
12+
This class manages the registration and lookup of derived value handlers.
13+
It provides a centralized way to:
14+
- Register new derived value handlers
15+
- Look up handlers by function name
16+
- Check if an attribute is a derived value
17+
18+
Example usage:
19+
registry = DerivedValueRegistry()
20+
registry.register(AddDaysHandler(default_days=91))
21+
22+
# Check if a token uses a derived value
23+
if registry.has_handler("ADD_DAYS"):
24+
handler = registry.get_handler("ADD_DAYS")
25+
result = handler.calculate(context)
26+
"""
27+
28+
# Class-level default handlers - these can be configured at startup
29+
_default_handlers: ClassVar[dict[str, DerivedValueHandler]] = {}
30+
31+
def __init__(self) -> None:
32+
"""Initialize the registry with default handlers."""
33+
self._handlers: dict[str, DerivedValueHandler] = {}
34+
# Copy default handlers to instance
35+
for name, handler in self._default_handlers.items():
36+
self._handlers[name] = handler
37+
38+
@classmethod
39+
def register_default(cls, handler: DerivedValueHandler) -> None:
40+
"""Register a handler as a default for all registry instances.
41+
42+
This is useful for configuring handlers at application startup.
43+
44+
Args:
45+
handler: The derived value handler to register
46+
"""
47+
cls._default_handlers[handler.function_name] = handler
48+
49+
@classmethod
50+
def clear_defaults(cls) -> None:
51+
"""Clear all default handlers. Useful for testing."""
52+
cls._default_handlers.clear()
53+
54+
@classmethod
55+
def get_default_handlers(cls) -> dict[str, DerivedValueHandler]:
56+
"""Get a copy of the default handlers. Useful for testing."""
57+
return cls._default_handlers.copy()
58+
59+
@classmethod
60+
def set_default_handlers(cls, handlers: dict[str, DerivedValueHandler]) -> None:
61+
"""Set the default handlers. Useful for testing."""
62+
cls._default_handlers = handlers
63+
64+
def register(self, handler: DerivedValueHandler) -> None:
65+
"""Register a derived value handler.
66+
67+
Args:
68+
handler: The handler to register. Its function_name attribute
69+
will be used as the lookup key.
70+
"""
71+
self._handlers[handler.function_name] = handler
72+
73+
def get_handler(self, function_name: str) -> DerivedValueHandler | None:
74+
"""Get a handler by function name.
75+
76+
Args:
77+
function_name: The function name (e.g., 'ADD_DAYS')
78+
79+
Returns:
80+
The handler or None if not found
81+
"""
82+
return self._handlers.get(function_name.upper())
83+
84+
def has_handler(self, function_name: str) -> bool:
85+
"""Check if a handler exists for a function name.
86+
87+
Args:
88+
function_name: The function name to check
89+
90+
Returns:
91+
True if a handler is registered
92+
"""
93+
return function_name.upper() in self._handlers
94+
95+
def is_derived_attribute(self, attribute_value: str) -> bool:
96+
"""Check if an attribute value represents a derived attribute.
97+
98+
This checks across all registered handlers.
99+
100+
Args:
101+
attribute_value: The attribute to check (e.g., 'NEXT_DOSE_DUE')
102+
103+
Returns:
104+
True if any handler can derive this attribute
105+
"""
106+
for handler in self._handlers.values():
107+
source = handler.get_source_attribute(attribute_value)
108+
if source != attribute_value:
109+
return True
110+
return False
111+
112+
def get_source_attribute(self, function_name: str, target_attribute: str) -> str:
113+
"""Get the source attribute for a derived attribute.
114+
115+
Args:
116+
function_name: The function name of the handler
117+
target_attribute: The target derived attribute
118+
119+
Returns:
120+
The source attribute name, or the target if no handler found
121+
"""
122+
handler = self.get_handler(function_name)
123+
if handler:
124+
return handler.get_source_attribute(target_attribute)
125+
return target_attribute
126+
127+
def calculate(
128+
self,
129+
function_name: str,
130+
context: DerivedValueContext,
131+
) -> str:
132+
"""Calculate a derived value.
133+
134+
Args:
135+
function_name: The function name (e.g., 'ADD_DAYS')
136+
context: The context containing all data needed for calculation
137+
138+
Returns:
139+
The calculated value as a string
140+
141+
Raises:
142+
ValueError: If no handler found for the function name
143+
"""
144+
handler = self.get_handler(function_name)
145+
if not handler:
146+
message = f"No handler registered for function: {function_name}"
147+
raise ValueError(message)
148+
149+
return handler.calculate(context)
150+
151+
152+
# Create a singleton instance for convenience
153+
_registry = DerivedValueRegistry()
154+
155+
156+
def get_registry() -> DerivedValueRegistry:
157+
"""Get the global derived value registry.
158+
159+
Returns:
160+
The singleton DerivedValueRegistry instance
161+
"""
162+
return _registry

0 commit comments

Comments
 (0)