Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from eligibility_signposting_api.services.processors.derived_values.add_days_handler import AddDaysHandler
from eligibility_signposting_api.services.processors.derived_values.base import (
DerivedValueContext,
DerivedValueHandler,
)
from eligibility_signposting_api.services.processors.derived_values.registry import (
DerivedValueRegistry,
get_registry,
)

__all__ = [
"AddDaysHandler",
"DerivedValueContext",
"DerivedValueHandler",
"DerivedValueRegistry",
"get_registry",
]

# Register default handlers
DerivedValueRegistry.register_default(
AddDaysHandler(
default_days=91,
vaccine_type_days={
"COVID": 91, # 91 days between COVID vaccinations
# Add other vaccine-specific configurations here as needed.
},
)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
from datetime import UTC, datetime, timedelta
from typing import ClassVar

from eligibility_signposting_api.services.processors.derived_values.base import (
DerivedValueContext,
DerivedValueHandler,
)


class AddDaysHandler(DerivedValueHandler):
"""Handler for adding days to a date value.

This handler calculates derived dates by adding a configurable number of days
to a source date attribute. It supports:
- Default days value for all vaccine types
- Vaccine-specific days configuration
- Configurable mapping of derived attributes to source attributes

Example token: [[TARGET.COVID.NEXT_DOSE_DUE:ADD_DAYS(91)]]
This would add 91 days to COVID's LAST_SUCCESSFUL_DATE to calculate NEXT_DOSE_DUE.

The number of days can be specified in three ways (in order of precedence):
1. In the token itself: :ADD_DAYS(91)
2. In the vaccine_type_days configuration
3. Using the default_days value
"""

function_name: str = "ADD_DAYS"

# Mapping of derived attribute names to their source attributes
DERIVED_ATTRIBUTE_SOURCES: ClassVar[dict[str, str]] = {
"NEXT_DOSE_DUE": "LAST_SUCCESSFUL_DATE",
}

def __init__(
self,
default_days: int = 91,
vaccine_type_days: dict[str, int] | None = None,
) -> None:
"""Initialize the AddDaysHandler.

Args:
default_days: Default number of days to add when not specified
in token or vaccine_type_days. Defaults to 91.
vaccine_type_days: Dictionary mapping vaccine types to their
specific days values. E.g., {"COVID": 91, "FLU": 365}
"""
self.default_days = default_days
self.vaccine_type_days = vaccine_type_days or {}

def get_source_attribute(self, target_attribute: str) -> str:
"""Get the source attribute for a derived attribute.

Args:
target_attribute: The derived attribute name (e.g., 'NEXT_DOSE_DUE')

Returns:
The source attribute name (e.g., 'LAST_SUCCESSFUL_DATE')
"""
return self.DERIVED_ATTRIBUTE_SOURCES.get(target_attribute, target_attribute)

def calculate(self, context: DerivedValueContext) -> str:
"""Calculate a date with added days.

Args:
context: DerivedValueContext containing:
- person_data: List of attribute dictionaries
- attribute_name: Vaccine type (e.g., 'COVID')
- source_attribute: The source date attribute
- function_args: Optional days override from token
- date_format: Optional output date format

Returns:
The calculated date as a formatted string

Raises:
ValueError: If source date is not found or invalid
"""
source_date = self._find_source_date(context)
if not source_date:
return ""

days_to_add = self._get_days_to_add(context)
calculated_date = self._add_days_to_date(source_date, days_to_add)

return self._format_date(calculated_date, context.date_format)

def _find_source_date(self, context: DerivedValueContext) -> str | None:
"""Find the source date value from person data.

Args:
context: The derived value context

Returns:
The source date string or None if not found
"""
source_attr = context.source_attribute
if not source_attr:
return None

for attribute in context.person_data:
if attribute.get("ATTRIBUTE_TYPE") == context.attribute_name:
return attribute.get(source_attr)

return None

def _get_days_to_add(self, context: DerivedValueContext) -> int:
"""Determine the number of days to add.

Priority:
1. Function argument from token (e.g., :ADD_DAYS(91))
2. Vaccine-specific configuration
3. Default days

Args:
context: The derived value context

Returns:
Number of days to add
"""
# Priority 1: Token argument
if context.function_args:
try:
return int(context.function_args)
except ValueError:
pass

# Priority 2: Vaccine-specific configuration
if context.attribute_name in self.vaccine_type_days:
return self.vaccine_type_days[context.attribute_name]

# Priority 3: Default
return self.default_days

def _add_days_to_date(self, date_str: str, days: int) -> datetime:
"""Parse a date string and add days.

Args:
date_str: Date in YYYYMMDD format
days: Number of days to add

Returns:
The calculated datetime

Raises:
ValueError: If date format is invalid
"""
try:
date_obj = datetime.strptime(date_str, "%Y%m%d").replace(tzinfo=UTC)
return date_obj + timedelta(days=days)
except ValueError as e:
message = f"Invalid date format: {date_str}"
raise ValueError(message) from e

def _format_date(self, date_obj: datetime, date_format: str | None) -> str:
"""Format a datetime object.

Args:
date_obj: The datetime to format
date_format: Optional strftime format string

Returns:
Formatted date string. If no format specified, returns YYYYMMDD.
"""
if date_format:
return date_obj.strftime(date_format)
return date_obj.strftime("%Y%m%d")
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any


@dataclass
class DerivedValueContext:
"""Context object containing all data needed for derived value calculation.

Attributes:
person_data: List of person attribute dictionaries
attribute_name: The condition/vaccine type (e.g., 'COVID', 'RSV')
source_attribute: The source attribute to derive from (e.g., 'LAST_SUCCESSFUL_DATE')
function_args: Arguments passed to the function (e.g., number of days)
date_format: Optional date format string for output formatting
"""

person_data: list[dict[str, Any]]
attribute_name: str
source_attribute: str | None
function_args: str | None
date_format: str | None


class DerivedValueHandler(ABC):
"""Abstract base class for derived value handlers.

Derived value handlers compute values that don't exist directly in the data
but are calculated from existing attributes. Each handler is responsible for
a specific type of calculation (e.g., adding days to a date).

To create a new derived value handler:
1. Subclass DerivedValueHandler
2. Set the `function_name` class attribute to the token function name (e.g., 'ADD_DAYS')
3. Implement the `calculate` method
4. Register the handler with the DerivedValueRegistry
"""

function_name: str = ""

@abstractmethod
def calculate(self, context: DerivedValueContext) -> str:
"""Calculate the derived value.

Args:
context: DerivedValueContext containing all necessary data

Returns:
The calculated value as a string

Raises:
ValueError: If the calculation cannot be performed
"""

@abstractmethod
def get_source_attribute(self, target_attribute: str) -> str:
"""Get the source attribute name needed for this derived value.

For example, NEXT_DOSE_DUE derives from LAST_SUCCESSFUL_DATE.

Args:
target_attribute: The target derived attribute name (e.g., 'NEXT_DOSE_DUE')

Returns:
The source attribute name to use for calculation
"""
Loading
Loading