Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/phone.py: 26%
38 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-02-07 19:28 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-02-07 19:28 +0000
1import re
2import typing as t
4from viur.core.bones.string import StringBone
5from viur.core.bones.base import ReadFromClientError, ReadFromClientErrorSeverity
7DEFAULT_REGEX = r"^\+?(\d{1,3})[-\s]?(\d{1,4})[-\s]?(\d{1,4})[-\s]?(\d{1,9})$"
10class PhoneBone(StringBone):
11 """
12 The PhoneBone class is designed to store validated phone/fax numbers in configurable formats.
13 This class provides a number validation method, ensuring that the given phone/fax number conforms to the
14 required/configured format and structure.
15 """
17 type: str = "str.phone"
18 """
19 A string representing the type of the bone, in this case "str.phone".
20 """
22 def __init__(
23 self,
24 *,
25 test: t.Optional[t.Pattern[str]] = DEFAULT_REGEX,
26 max_length: int = 15, # maximum allowed numbers according to ITU-T E.164
27 default_country_code: t.Optional[str] = None,
28 **kwargs: t.Any,
29 ) -> None:
30 """
31 Initializes the PhoneBone with an optional custom regex for phone number validation, a default country code,
32 and a flag to apply the default country code if none is provided.
34 :param test: An optional custom regex pattern for phone number validation.
35 :param max_length: The maximum length of the phone number. Passed to "StringBone".
36 :param default_country_code: The default country code to apply (with leading +) for example "+49"
37 If None is provided the PhoneBone will ignore auto prefixing of the country code.
38 :param kwargs: Additional keyword arguments. Passed to "StringBone".
39 :raises ValueError: If the default country code is not in the correct format for example "+123".
40 """
41 if default_country_code and not re.match(r"^\+\d{1,3}$", default_country_code):
42 raise ValueError(f"Invalid default country code format: {default_country_code}")
44 self.test: t.Pattern[str] = re.compile(test) if isinstance(test, str) else test
45 self.default_country_code: t.Optional[str] = default_country_code
46 super().__init__(max_length=max_length, **kwargs)
48 @staticmethod
49 def _extract_digits(value: str) -> str:
50 """
51 Extracts and returns only the digits from the given value.
53 :param value: The input string from which to extract digits.
54 :return: A string containing only the digits from the input value.
55 """
56 return re.sub(r"[^\d+]", "", value)
58 def isInvalid(self, value: str) -> t.Optional[str]:
59 """
60 Checks if the provided phone number is valid or not.
62 :param value: The phone number to be validated.
63 :return: An error message if the phone number is invalid or None if it is valid.
65 The method checks if the provided phone number is valid according to the following criteria:
66 1. The phone number must not be empty.
67 2. The phone number must match the provided or default phone number format.
68 3. The phone number cannot exceed 15 digits, or the specified maximum length if provided (digits only).
69 """
70 if not value:
71 return "No value entered"
73 if self.test and not self.test.match(value):
74 return "Invalid phone number entered"
76 # make sure max_length is not exceeded.
77 if is_invalid := super().isInvalid(self._extract_digits(value)):
78 return is_invalid
80 return None
82 def singleValueFromClient(
83 self, value: str, skel: t.Any, bone_name: str, client_data: t.Any
84 ) -> t.Tuple[t.Optional[str], t.Optional[t.List[ReadFromClientError]]]:
85 """
86 Processes a single value from the client, applying the default country code if necessary and validating the
87 phone number.
89 :param value: The phone number provided by the client.
90 :param skel: Skeleton data (not used in this method).
91 :param bone_name: The name of the bone (not used in this method).
92 :param client_data: Additional client data (not used in this method).
93 :return: A tuple containing the processed phone number and an optional list of errors.
94 """
95 value = value.strip()
97 # Replace country code starting with 00 with +
98 if value.startswith("00"):
99 value = "+" + value[2:]
101 # Apply default country code if none is provided and default_country_code is set
102 if self.default_country_code and value[0] != "+":
103 if value.startswith("0"):
104 value = value[1:] # Remove leading 0 from city code
105 value = f"{self.default_country_code} {value}"
107 if err := self.isInvalid(value):
108 return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid, err)]
110 return value, None
112 def structure(self) -> t.Dict[str, t.Any]:
113 """
114 Returns the structure of the PhoneBone, including the test regex pattern.
116 :return: A dictionary representing the structure of the PhoneBone.
117 """
118 return super().structure() | {
119 "test": self.test.pattern if self.test else "",
120 "default_country_code": self.default_country_code,
121 }