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