Skip to content

Commit 6fea184

Browse files
Feature/bcss 21315 fobtregressiontests scenario 11 (#143)
<!-- markdownlint-disable-next-line first-line-heading --> ## Description <!-- Describe your changes in detail. --> Adding scenario 11 from FOBTRegressionTests. Adding new PersonRepository to be able to fetch a person matching a certain criteria. Altered book post investigation util to dynamically add 15 minutes to the start time if there is already an appointment at that slot. This goes until 17:00. Altered batch_processing to make the latest event status checks optional. ## Context <!-- Why is this change required? What problem does it solve? --> Adds a new scenario and utils that can be used in other tests. ## Type of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply. --> - [x] Refactoring (non-breaking change) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would change existing functionality) - [ ] Bug fix (non-breaking change which fixes an issue) ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I am familiar with the [contributing guidelines](https://github.com/nhs-england-tools/playwright-python-blueprint/blob/main/CONTRIBUTING.md) - [x] I have followed the code style of the project - [x] I have added tests to cover my changes (where appropriate) - [x] I have updated the documentation accordingly - [ ] This PR is a result of pair or mob programming --- ## Sensitive Information Declaration To ensure the utmost confidentiality and protect your and others privacy, we kindly ask you to NOT including [PII (Personal Identifiable Information) / PID (Personal Identifiable Data)](https://digital.nhs.uk/data-and-information/keeping-data-safe-and-benefitting-the-public) or any other sensitive data in this PR (Pull Request) and the codebase changes. We will remove any PR that do contain any sensitive information. We really appreciate your cooperation in this matter. - [x] I confirm that neither PII/PID nor sensitive data are included in this PR and the codebase changes. --------- Co-authored-by: AndyG <[email protected]>
1 parent 8ff05c5 commit 6fea184

File tree

16 files changed

+2081
-106
lines changed

16 files changed

+2081
-106
lines changed
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
from datetime import datetime, timedelta, date
2+
from typing import Optional
3+
import logging
4+
from classes.date.date_description import DateDescription
5+
6+
7+
class DateDescriptionUtils:
8+
"""
9+
Utility class for interpreting and converting date descriptions to Python date objects or formatted strings.
10+
"""
11+
12+
DATE_FORMAT_YYYY_MM_DD = "%Y-%m-%d"
13+
DATE_FORMAT_DD_MM_YYYY = "%d/%m/%Y"
14+
15+
# string constants
16+
NULL_STRING = "NULL"
17+
NOT_NULL_STRING_UNDERSCORE = "NOT_NULL"
18+
NOT_NULL_STRING = "NOT NULL"
19+
20+
@staticmethod
21+
def interpret_date(date_field_name: str, date_value: str) -> str:
22+
"""
23+
Interprets a date description and returns a formatted date string (dd/MM/yyyy).
24+
If the date cannot be interpreted, returns the original value.
25+
Args:
26+
date_field_name (str): The name of the date field (for logging purposes).
27+
date_value (str): The date description to interpret. E.g., "2 years ago", "NULL", "15/08/2020".
28+
"""
29+
logging.debug(f"interpret_date: {date_field_name}, {date_value}")
30+
try:
31+
return DateDescriptionUtils.convert_description_to_string_date(
32+
date_field_name, date_value, DateDescriptionUtils.DATE_FORMAT_DD_MM_YYYY
33+
)
34+
except Exception as e:
35+
logging.error(f"Could not interpret date: {e}")
36+
return date_value
37+
38+
@staticmethod
39+
def convert_description_to_sql_date(
40+
which_date: str, date_description: str
41+
) -> Optional[str]:
42+
"""
43+
Converts a date description to an Oracle TO_DATE SQL string.
44+
Returns None if the date cannot be interpreted.
45+
Args:
46+
which_date (str): A label for the date being converted (for logging purposes). E.g., "start_date", "end_date".
47+
date_description (str): The date description to convert. E.g., "2 years ago", "NULL", "15/08/2020".
48+
"""
49+
logging.debug(
50+
f"convert_description_to_sql_date: {which_date}, {date_description}"
51+
)
52+
return_date_string = None
53+
return_date = None
54+
55+
date_description_words = date_description.split(" ")
56+
57+
# First handle actual dates
58+
if DateDescriptionUtils.is_valid_date(
59+
date_description, DateDescriptionUtils.DATE_FORMAT_DD_MM_YYYY
60+
):
61+
return_date_string = DateDescriptionUtils.oracle_to_date_function(
62+
date_description, "dd/mm/yyyy"
63+
)
64+
elif DateDescriptionUtils.is_valid_date(
65+
date_description, DateDescriptionUtils.DATE_FORMAT_YYYY_MM_DD
66+
):
67+
return_date_string = DateDescriptionUtils.oracle_to_date_function(
68+
date_description, "yyyy-mm-dd"
69+
)
70+
elif date_description.endswith(" ago") and len(date_description_words) == 3:
71+
return_date = DateDescriptionUtils.convert_description_to_local_date(
72+
which_date, date_description
73+
)
74+
else:
75+
# If the date description is in the enum, use the suggested suitable date, plus allow for NULL and NOT NULL
76+
enum_val = DateDescription.by_description_case_insensitive(date_description)
77+
if enum_val is not None:
78+
if enum_val.name == DateDescriptionUtils.NULL_STRING:
79+
return_date_string = DateDescriptionUtils.NULL_STRING
80+
elif enum_val.name == DateDescriptionUtils.NOT_NULL_STRING_UNDERSCORE:
81+
return_date_string = DateDescriptionUtils.NOT_NULL_STRING
82+
else:
83+
return_date = enum_val.suitable_date
84+
85+
if return_date is not None and return_date_string is None:
86+
return_date_string = DateDescriptionUtils.oracle_to_date_function(
87+
return_date.strftime(DateDescriptionUtils.DATE_FORMAT_DD_MM_YYYY),
88+
"dd/mm/yyyy",
89+
)
90+
91+
return return_date_string
92+
93+
@staticmethod
94+
def convert_description_to_local_date(
95+
which_date: str, date_description: str
96+
) -> date:
97+
"""
98+
Converts a date description to a Python date object.
99+
Raises ValueError if the description cannot be interpreted.
100+
Args:
101+
which_date (str): A label for the date being converted (for logging purposes). E.g., "start_date", "end_date".
102+
date_description (str): The date description to convert. E.g., "2 years ago", "NULL", "15/08/2020".
103+
"""
104+
logging.debug(
105+
f"convert_description_to_local_date: {which_date}, {date_description}"
106+
)
107+
date_description_words = date_description.split(" ")
108+
109+
# Handle actual dates
110+
if DateDescriptionUtils.is_valid_date(
111+
date_description, DateDescriptionUtils.DATE_FORMAT_DD_MM_YYYY
112+
):
113+
return datetime.strptime(
114+
date_description, DateDescriptionUtils.DATE_FORMAT_DD_MM_YYYY
115+
).date()
116+
if DateDescriptionUtils.is_valid_date(
117+
date_description, DateDescriptionUtils.DATE_FORMAT_YYYY_MM_DD
118+
):
119+
return datetime.strptime(
120+
date_description, DateDescriptionUtils.DATE_FORMAT_YYYY_MM_DD
121+
).date()
122+
123+
# Handle relative dates ("ago" or "ahead")
124+
if DateDescriptionUtils._is_relative_date(
125+
date_description, date_description_words, "ago"
126+
):
127+
return DateDescriptionUtils._calculate_relative_date(
128+
date_description_words, is_ago=True
129+
)
130+
if DateDescriptionUtils._is_relative_date(
131+
date_description, date_description_words, "ahead"
132+
):
133+
return DateDescriptionUtils._calculate_relative_date(
134+
date_description_words, is_ago=False
135+
)
136+
137+
# Handle enum-based descriptions
138+
enum_val = DateDescription.by_description_case_insensitive(date_description)
139+
if enum_val is not None:
140+
if enum_val.name in [
141+
DateDescriptionUtils.NULL_STRING,
142+
DateDescriptionUtils.NOT_NULL_STRING_UNDERSCORE,
143+
]:
144+
raise ValueError(f"Cannot convert '{date_description}' to a date.")
145+
if enum_val.suitable_date is not None:
146+
return enum_val.suitable_date
147+
148+
raise ValueError(f"Cannot interpret date description '{date_description}'.")
149+
150+
@staticmethod
151+
def _is_relative_date(date_description, words, suffix):
152+
"""Checks if the date description is a relative date ending with the specified suffix.
153+
Args:
154+
date_description (str): The full date description string.
155+
words (list): The split words of the date description.
156+
suffix (str): The suffix to check for ("ago" or "ahead").
157+
"""
158+
return date_description.endswith(f" {suffix}") and len(words) == 3
159+
160+
@staticmethod
161+
def _calculate_relative_date(words, is_ago):
162+
"""Calculates the date based on the relative description.
163+
Args:
164+
words (list): The split words of the date description. E.g., ["2", "years", "ago"].
165+
is_ago (bool): Whether the date is in the past (True) or future (False).
166+
"""
167+
if not words[0].isdigit():
168+
raise ValueError(f"Invalid period number in '{' '.join(words)}'")
169+
number_of_periods = int(words[0])
170+
period_type = words[1]
171+
today = date.today()
172+
delta_days = DateDescriptionUtils._get_delta_days(
173+
number_of_periods, period_type
174+
)
175+
if is_ago:
176+
return today - timedelta(days=delta_days)
177+
else:
178+
return today + timedelta(days=delta_days)
179+
180+
@staticmethod
181+
def _get_delta_days(number_of_periods, period_type):
182+
"""Returns the number of days corresponding to the given period type and number.
183+
Args:
184+
number_of_periods (int): The number of periods (e.g., 2).
185+
period_type (str): The type of period (e.g., "years", "months", "weeks", "days").
186+
"""
187+
if period_type in ["year", "years"]:
188+
return number_of_periods * 365
189+
if period_type in ["month", "months"]:
190+
return number_of_periods * 30
191+
if period_type in ["week", "weeks"]:
192+
return number_of_periods * 7
193+
if period_type in ["day", "days"]:
194+
return number_of_periods
195+
raise ValueError(f"Unknown period type '{period_type}'")
196+
197+
@staticmethod
198+
def convert_description_to_string_date(
199+
which_date: str, date_description: str, date_format: str
200+
) -> str:
201+
"""
202+
Converts a date description to a formatted date string.
203+
Raises ValueError if the description cannot be interpreted.
204+
Args:
205+
which_date (str): A label for the date being converted (for logging purposes). E.g., "start_date", "end_date".
206+
date_description (str): The date description to convert. E.g., "2 years ago", "NULL", "15/08/2020".
207+
date_format (str): The desired output date format. E.g., "%d/%m/%Y".
208+
Returns:
209+
str: The formatted date string.
210+
"""
211+
logging.debug(
212+
f"convert_description_to_string_date: {which_date}, {date_description}, {date_format}"
213+
)
214+
local_date = DateDescriptionUtils.convert_description_to_local_date(
215+
which_date, date_description
216+
)
217+
return local_date.strftime(date_format)
218+
219+
@staticmethod
220+
def is_valid_date(date_str: str, date_format: str) -> bool:
221+
"""
222+
Checks if the given string is a valid date in the specified format.
223+
Args:
224+
date_str (str): The date string to validate.
225+
date_format (str): The format to validate against. E.g., "%d/%m/%Y".
226+
Returns:
227+
bool: True if the string is a valid date, False otherwise.
228+
"""
229+
try:
230+
datetime.strptime(date_str, date_format)
231+
return True
232+
except ValueError:
233+
return False
234+
235+
@staticmethod
236+
def oracle_to_date_function(date_str: str, oracle_format: str) -> str:
237+
"""
238+
Constructs an Oracle TO_DATE function to convert a string to a date.
239+
This method formats the date string according to the specified format.
240+
Args:
241+
date_str (str): The date string to be converted.
242+
oracle_format (str): The format in which the date string is provided.
243+
Returns:
244+
str: The SQL expression for the Oracle TO_DATE function.
245+
"""
246+
return f" TO_DATE( '{date_str}', '{oracle_format}') "

classes/person/person.py

Lines changed: 34 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,49 @@
1-
import logging
1+
from dataclasses import dataclass, field
22
from typing import Optional
3-
from classes.subject.gender_type import GenderType
43

54

5+
@dataclass
66
class Person:
77
"""
8-
Represents a person with name, title, gender, and other attributes.
8+
Represents a person with identifying and role-related attributes.
9+
10+
Attributes:
11+
person_id (Optional[int]): Unique identifier for the person (PRS ID).
12+
surname (Optional[str]): The surname of the person.
13+
forenames (Optional[str]): The forenames of the person.
14+
registration_code (Optional[str]): The registration code of the person.
15+
role_id (Optional[int]): Role identifier (PIO ID). Used to hold one
16+
selected role for the person.
917
"""
1018

11-
def __init__(self) -> None:
12-
self.surname: str = ""
13-
self.forename: str = ""
14-
self.title: str = ""
15-
self.other_forenames: str = ""
16-
self.previous_surname: str = ""
17-
self.gender: Optional[GenderType] = GenderType.NOT_KNOWN
19+
person_id: Optional[int] = field(default=None)
20+
surname: Optional[str] = field(default=None)
21+
forenames: Optional[str] = field(default=None)
22+
registration_code: Optional[str] = field(default=None)
23+
role_id: Optional[int] = field(default=None)
1824

19-
def get_full_name(self) -> str:
25+
@property
26+
def full_name(self) -> str:
2027
"""
21-
Returns the full name of the person, including title, forename, and surname.
22-
"""
23-
return f"{self.title} {self.forename} {self.surname}"
24-
25-
def __str__(self) -> str:
26-
"""
27-
Returns a string representation of the person, including gender.
28-
"""
29-
return f"{self.title} {self.forename} {self.surname} (gender={self.gender})"
30-
31-
def get_surname(self) -> str:
32-
"""
33-
Returns the surname of the person.
34-
"""
35-
return self.surname
36-
37-
def set_surname(self, surname: str) -> None:
38-
"""
39-
Sets the surname of the person.
40-
"""
41-
logging.debug("set surname")
42-
self.surname = surname
43-
44-
def get_forename(self) -> str:
45-
"""
46-
Returns the forename of the person.
47-
"""
48-
return self.forename
49-
50-
def set_forename(self, forename: str) -> None:
51-
"""
52-
Sets the forename of the person.
53-
"""
54-
logging.debug("set forename")
55-
self.forename = forename
56-
57-
def get_title(self) -> str:
58-
"""
59-
Returns the title of the person.
60-
"""
61-
return self.title
62-
63-
def set_title(self, title: str) -> None:
64-
"""
65-
Sets the title of the person.
66-
"""
67-
logging.debug("set title")
68-
self.title = title
69-
70-
def get_other_forenames(self) -> str:
71-
"""
72-
Returns other forenames of the person.
73-
"""
74-
return self.other_forenames
75-
76-
def set_other_forenames(self, other_forenames: str) -> None:
77-
"""
78-
Sets other forenames of the person.
79-
"""
80-
logging.debug("set other_forenames")
81-
self.other_forenames = other_forenames
28+
Get the full name of the person.
8229
83-
def get_previous_surname(self) -> str:
84-
"""
85-
Returns the previous surname of the person.
30+
Returns:
31+
str: The concatenation of forenames and surname, or an empty string if missing.
8632
"""
87-
return self.previous_surname
33+
parts = [self.forenames or "", self.surname or ""]
34+
return " ".join(p for p in parts if p).strip()
8835

89-
def set_previous_surname(self, previous_surname: str) -> None:
90-
"""
91-
Sets the previous surname of the person.
92-
"""
93-
logging.debug("set previous_surname")
94-
self.previous_surname = previous_surname
95-
96-
def get_gender(self) -> Optional[GenderType]:
97-
"""
98-
Returns the gender of the person.
36+
def __str__(self) -> str:
9937
"""
100-
return self.gender
38+
Get a string representation of the person object.
10139
102-
def set_gender(self, gender: GenderType) -> None:
103-
"""
104-
Sets the gender of the person.
40+
Returns:
41+
str: String with field values, similar to the Java `toString` implementation.
10542
"""
106-
logging.debug("set gender")
107-
self.gender = gender
43+
return (
44+
f"User [personId={self.person_id}, "
45+
f"surname={self.surname}, "
46+
f"forenames={self.forenames}, "
47+
f"registrationCode={self.registration_code}, "
48+
f"roleId={self.role_id}]"
49+
)

0 commit comments

Comments
 (0)