generated from NHSDigital/repository-template
-
Notifications
You must be signed in to change notification settings - Fork 2
Feature/eja eli 579 adding derivation for previous dose calculations #515
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
eddalmond1
merged 25 commits into
main
from
feature/eja-eli-579-adding-derivation-for-previous-dose-calculations
Jan 22, 2026
Merged
Changes from all commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
4e1ead4
eli-579 adding a base class for derived field calculations, with aim …
eddalmond1 9b4f10d
eli-579 adding an add_days derivation
eddalmond1 c023070
eli-579 amending token parser to parse out function details
eddalmond1 28d2e75
eli-579 amending token processor to use new token parser
eddalmond1 8f45ba6
eli-579 initialising the AddDaysHandler in the registry on app start
eddalmond1 50668e8
eli-579 amending error message match now that the invalid date format…
eddalmond1 8b8ed0f
eli-579 adding an integration tests to make sure add_days plus add_da…
eddalmond1 e9cd520
eli-579 file formatting
eddalmond1 122b6dc
Merge branch 'main' into feature/eja-eli-579-adding-derivation-for-pr…
eddalmond1 236a9a1
chore - bumping test data date
eddalmond1 c39bc7d
eli-579 re-adding fixtures after panic removing them due to non-relat…
eddalmond1 c9ce52b
eli-579 safer handling of non-numeric values if passed to ADD_DAYS
eddalmond1 d47a14c
Merge branch 'main' into feature/eja-eli-579-adding-derivation-for-pr…
eddalmond1 d656bcf
eli-579 updating tests to use hamcrest
eddalmond1 afea6a3
eli-579 refactoring tests to use hamcrest
eddalmond1 42c3776
eli-579 adding integration test to test that functions can use multip…
eddalmond1 18e140b
Merge branch 'main' into feature/eja-eli-579-adding-derivation-for-pr…
eddalmond1 cf832bf
Merge branch 'main' into feature/eja-eli-579-adding-derivation-for-pr…
eddalmond1 36f6cda
eli-579 allowing custom target field names and source fields
eddalmond1 5f06436
Merge branch 'main' into feature/eja-eli-579-adding-derivation-for-pr…
eddalmond1 f0f6f05
eli-579 tightening up registry initialisation
eddalmond1 778ccd3
Merge branch 'main' into feature/eja-eli-579-adding-derivation-for-pr…
eddalmond1 c609690
eli-579 amending token processor to use get_registry
eddalmond1 b0b7c68
Merge branch 'main' into feature/eja-eli-579-adding-derivation-for-pr…
eddalmond1 03cea5b
Merge branch 'main' into feature/eja-eli-579-adding-derivation-for-pr…
eddalmond1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
28 changes: 28 additions & 0 deletions
28
src/eligibility_signposting_api/services/processors/derived_values/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| }, | ||
| ) | ||
| ) | ||
180 changes: 180 additions & 0 deletions
180
src/eligibility_signposting_api/services/processors/derived_values/add_days_handler.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| from datetime import UTC, datetime, timedelta | ||
eddalmond1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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, function_args: str | None = None) -> str: | ||
| """Get the source attribute for a derived attribute. | ||
|
|
||
| Check if source is provided in function args (e.g., ADD_DAYS(91, SOURCE_FIELD)). | ||
| If not, fall back to mapping or return target_attribute as default. | ||
|
|
||
| Args: | ||
| target_attribute: The derived attribute name (e.g., 'NEXT_DOSE_DUE') | ||
| function_args: Optional arguments from token (e.g., '91, LAST_SUCCESSFUL_DATE') | ||
|
|
||
| Returns: | ||
| The source attribute name (e.g., 'LAST_SUCCESSFUL_DATE') | ||
| """ | ||
| if function_args and "," in function_args: | ||
| # Extract source from args if present (second argument) | ||
| parts = [p.strip() for p in function_args.split(",")] | ||
| if len(parts) > 1 and parts[1]: | ||
| return parts[1].upper() | ||
|
|
||
| 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 non-empty) | ||
| if context.function_args: | ||
| args = context.function_args.split(",")[0].strip() | ||
| if args: | ||
| try: | ||
| return int(args) | ||
| except ValueError as e: | ||
| message = f"Invalid days argument '{args}' for ADD_DAYS function. Expected an integer." | ||
| raise ValueError(message) from e | ||
|
|
||
| # 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") | ||
67 changes: 67 additions & 0 deletions
67
src/eligibility_signposting_api/services/processors/derived_values/base.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| from abc import ABC, abstractmethod | ||
eddalmond1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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, function_args: str | None = None) -> 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') | ||
| function_args: Optional arguments from the token function call | ||
|
|
||
| Returns: | ||
| The source attribute name to use for calculation | ||
| """ | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.