csu 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
csu/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ a.k.a Clean Slate Utils
3
+ """
4
+
5
+ __version__ = "1.0.0"
csu/admin.py ADDED
@@ -0,0 +1,116 @@
1
+ from django.contrib.admin import DateFieldListFilter
2
+ from django.core.exceptions import ValidationError
3
+ from django.db import models
4
+ from django.db.models import Choices
5
+ from django.db.models.expressions import Col
6
+ from django.db.models.lookups import Exact
7
+ from django.db.models.sql import AND
8
+ from django.utils.encoding import force_str
9
+ from django.utils.tree import Node
10
+ from import_export.fields import Field
11
+ from import_export.resources import ModelResource
12
+ from import_export.widgets import Widget
13
+ from rest_framework import serializers
14
+
15
+ from .timezones import today
16
+
17
+
18
+ class PastDateListFilter(DateFieldListFilter):
19
+ def __init__(self, field, request, params, model, model_admin, field_path):
20
+ super().__init__(field, request, params, model, model_admin, field_path)
21
+ self.links = [
22
+ (label, {self.lookup_kwarg_until: filters[self.lookup_kwarg_since]} if self.lookup_kwarg_until in filters else filters)
23
+ for label, filters in self.links
24
+ ]
25
+
26
+
27
+ def repr_expr(obj):
28
+ if isinstance(obj, Exact):
29
+ return f"{repr_expr(obj.lhs)}={repr_expr(obj.rhs)}"
30
+ elif isinstance(obj, Col):
31
+ return obj.target.column
32
+ elif isinstance(obj, Node):
33
+ if obj.connector == AND:
34
+ return "-".join(map(repr_expr, obj.children))
35
+ else:
36
+ return "~".join(map(repr_expr, obj.children))
37
+ else:
38
+ return str(obj)
39
+
40
+
41
+ class FullFilenameExportAdminMixin:
42
+ model: models.Model
43
+
44
+ def get_export_filename(self, request, queryset: models.QuerySet, file_format):
45
+ filters = repr_expr(queryset.query.where)
46
+ filename = "{}-{}-{}-{}.{}".format(
47
+ self.model._meta.app_label,
48
+ self.model._meta.verbose_name_plural.replace(" ", "-").replace("/", "-"),
49
+ today().isoformat(),
50
+ filters or "all",
51
+ file_format.get_extension(),
52
+ )
53
+ return filename
54
+
55
+
56
+ class ChoiceWidget(Widget):
57
+ def __init__(self, choice_class: type[Choices]):
58
+ self.choice_class = choice_class
59
+
60
+ def render(self, value, obj=None):
61
+ if value in self.choice_class:
62
+ return self.choice_class(value).name
63
+ else:
64
+ return value
65
+
66
+ def clean(self, value, row=None, **kwargs):
67
+ if value in self.choice_class:
68
+ return self.choice_class[value]
69
+
70
+
71
+ class SmartField(Field):
72
+ def clean(self, data, **kwargs):
73
+ """
74
+ Perform exception wrapping early here (instead of letting import_obj do it).
75
+ Apparently that behavior is completely missing from (for import_id_fields).
76
+ """
77
+ try:
78
+ return super().clean(data, **kwargs)
79
+ except ValueError as e:
80
+ raise ValidationError({self.attribute: force_str(e)}, code="invalid") from e
81
+
82
+
83
+ def widget_passthrough(widget_instance):
84
+ def widget_trampoline(key_is_id=None, **kwargs):
85
+ assert not kwargs, f"no widget kwargs are allowed but we got: {kwargs!r}"
86
+ return widget_instance
87
+
88
+ return widget_trampoline
89
+
90
+
91
+ def widget_for_drf_field(drf_field: serializers.Field):
92
+ class WidgetWrapper(Widget):
93
+ def clean(self, value, row=None, **kwargs):
94
+ try:
95
+ return drf_field.run_validation(value)
96
+ except serializers.ValidationError as exc:
97
+ raise ValueError(*map(str, exc.detail)) from None
98
+
99
+ return WidgetWrapper()
100
+
101
+
102
+ class SmartModelResource(ModelResource):
103
+ DEFAULT_RESOURCE_FIELD = SmartField
104
+
105
+ @classmethod
106
+ def widget_kwargs_for_field(self, *args):
107
+ return {}
108
+
109
+ @classmethod
110
+ def widget_from_django_field(cls, f: models.Field, *args, **kwargs):
111
+ name = f.name
112
+ widgets = cls._meta.widgets or ()
113
+ if name in widgets:
114
+ return widget_passthrough(widgets[name])
115
+ else:
116
+ return widget_passthrough(super().widget_from_django_field(f, *args, **kwargs)())
csu/conf.py ADDED
@@ -0,0 +1,19 @@
1
+ from datetime import datetime
2
+ from datetime import timezone
3
+ from importlib.util import find_spec
4
+ from zoneinfo import ZoneInfo
5
+
6
+ if find_spec("django"):
7
+ from django.conf import settings
8
+ else:
9
+ settings = None
10
+
11
+ WSGI_BUFFER_INPUT_LIMIT = int(getattr(settings, "DATA_UPLOAD_MAX_MEMORY_SIZE", 25 * 1024 * 1024))
12
+ DRF_BEARER_TOKEN = getattr(settings, "BEARER_API_TOKEN", None)
13
+
14
+ UTC = timezone.utc
15
+
16
+ if hasattr(settings, "TIME_ZONE"):
17
+ TIME_ZONE = ZoneInfo(settings.TIME_ZONE)
18
+ else:
19
+ TIME_ZONE = datetime.now(UTC).astimezone().tzinfo
csu/consts.py ADDED
@@ -0,0 +1,70 @@
1
+ import re
2
+
3
+ # Fields stuff.
4
+ CYRILLIC_CONVERSION_TABLE = str.maketrans(
5
+ {
6
+ "А": "A",
7
+ "В": "B",
8
+ "Е": "E",
9
+ "К": "K",
10
+ "М": "M",
11
+ "Н": "H",
12
+ "О": "O",
13
+ "Р": "P",
14
+ "С": "C",
15
+ "Т": "T",
16
+ "У": "Y",
17
+ "Х": "X",
18
+ # lowercase
19
+ "а": "A",
20
+ "в": "B",
21
+ "е": "E",
22
+ "к": "K",
23
+ "м": "M",
24
+ "н": "H",
25
+ "о": "O",
26
+ "р": "P",
27
+ "с": "C",
28
+ "т": "T",
29
+ "у": "Y",
30
+ "х": "X",
31
+ }
32
+ )
33
+ NON_WORD_RE = re.compile(r"[^a-zA-Z0-9]+")
34
+ NON_DIGIT_RE = re.compile(r"[^0-9]+")
35
+ SPACES_RE = re.compile(r"\s+")
36
+ REGISTRATION_NUMBER_RE = {
37
+ "HU": re.compile(r"^(?=(CD)?.{6,7}$)[A-Z][A-Z]*[0-9]+$"),
38
+ "RO": re.compile(
39
+ r"""^(
40
+ (MAI|A|CD|TC|FA|ALA)\d{1,6}
41
+ |
42
+ (AB|AG|AR|BC|BH|BN|BR|BT|BV|BZ|CJ|CL|CS|CT|CV|DB|DJ|GJ|GL|GR|
43
+ HD|HR|IF|IL|IS|MH|MM|MS|NT|OT|PH|SB|SJ|SM|SV|TL|TM|TR|VL|VN|VS)
44
+ (
45
+ (?!00)\d{2}[A-Z]{3}
46
+ )
47
+ |
48
+ B(
49
+ (?!00)[0-9]{2}[A-Z]{3}
50
+ |
51
+ (?!0)[0-9]{3}[A-Z]{3}
52
+ )
53
+ )$""",
54
+ re.VERBOSE,
55
+ ),
56
+ }
57
+
58
+ # Logging stuff.
59
+ LINE_LENGTH = 140
60
+ THICK_LINE = "=" * LINE_LENGTH
61
+ CONTEXT_LINE = " context ".center(LINE_LENGTH, "-")
62
+ REQUEST_CONTENT_LINE = " request content ".center(LINE_LENGTH, "-")
63
+ REQUEST_DATA_LINE = " request data ".center(LINE_LENGTH, "-")
64
+ REQUEST_OVERSIZE_LINE = " request oversize (first 10kb) ".center(LINE_LENGTH, "-")
65
+ RESPONSE_CONTENT_LINE = " response content ".center(LINE_LENGTH, "-")
66
+ RESPONSE_DATA_LINE = " response data ".center(LINE_LENGTH, "-")
67
+ RESPONSE_EXCEPTION_LINE = " response exception ".center(LINE_LENGTH, "-")
68
+ RESPONSE_LINE = " response ".center(LINE_LENGTH, "-")
69
+ RESPONSE_UNKNOWN_LINE = " response (unknown) ".center(LINE_LENGTH, "-")
70
+ TRACEBACK_LINE = " traceback ".center(LINE_LENGTH, "-")
csu/drf/__init__.py ADDED
File without changes
csu/drf/auth.py ADDED
@@ -0,0 +1,71 @@
1
+ from hmac import compare_digest
2
+
3
+ from asgiref.sync import sync_to_async
4
+ from rest_framework import exceptions
5
+ from rest_framework.authentication import BaseAuthentication
6
+ from rest_framework.authentication import SessionAuthentication as BaseSessionAuthentication
7
+ from rest_framework.authentication import get_authorization_header
8
+ from rest_framework.permissions import BasePermission
9
+ from rest_framework.request import Request
10
+
11
+ from ..conf import DRF_BEARER_TOKEN
12
+ from ..gettext_lazy import _
13
+
14
+
15
+ class AnyAuthentication(BasePermission):
16
+ """
17
+ Allows access only to authenticated users.
18
+ """
19
+
20
+ def has_permission(self, request, view):
21
+ return bool(request.user and request.user.is_authenticated or request.auth)
22
+
23
+
24
+ class BearerTokenAuthentication(BaseAuthentication):
25
+ """
26
+ Simple token based authentication.
27
+
28
+ Clients should authenticate by passing the token key in the "Authorization"
29
+ HTTP header, prepended with the string "Token ". For example:
30
+
31
+ Authorization: Bearer 401f7ac837da42b97f613d789819ff93537bee6a
32
+ """
33
+
34
+ keyword = "Bearer"
35
+ keyword_test = keyword.lower().encode()
36
+ expected_token_value = DRF_BEARER_TOKEN
37
+
38
+ async def aauthenticate(self, request: Request):
39
+ return self.authenticate(request)
40
+
41
+ def authenticate(self, request: Request):
42
+ auth = get_authorization_header(request).split()
43
+
44
+ if not auth or auth[0].lower() != self.keyword_test:
45
+ return None
46
+
47
+ if len(auth) == 1:
48
+ raise exceptions.AuthenticationFailed(_("Invalid Authorization header: no credentials provided."))
49
+ elif len(auth) > 2:
50
+ raise exceptions.AuthenticationFailed(_("Invalid Authorization header: token contains spaces."))
51
+
52
+ try:
53
+ token = auth[1].decode()
54
+ except UnicodeError:
55
+ raise exceptions.AuthenticationFailed(_("Invalid Authorization header: token contains invalid characters.")) from None
56
+
57
+ if compare_digest(token, self.expected_token_value):
58
+ return None, token
59
+ else:
60
+ raise exceptions.AuthenticationFailed(_("Invalid Authorization header: token invalid."))
61
+
62
+ def authenticate_header(self, request):
63
+ return self.keyword
64
+
65
+
66
+ class SessionAuthentication(BaseSessionAuthentication):
67
+ async def aauthenticate(self, request: Request):
68
+ return await sync_to_async(self.authenticate, thread_sensitive=False)(request)
69
+
70
+ def authenticate(self, request: Request):
71
+ return super().authenticate(request)
csu/drf/fields.py ADDED
@@ -0,0 +1,203 @@
1
+ from zoneinfo import ZoneInfo
2
+
3
+ from django.conf import settings
4
+ from django.core.validators import RegexValidator
5
+ from django.db.models import Choices
6
+ from rest_framework import serializers
7
+ from rest_framework.fields import CharField
8
+ from rest_framework.fields import ChoiceField
9
+ from rest_framework.fields import ReadOnlyField
10
+ from rest_framework.relations import HyperlinkedRelatedField
11
+
12
+ from ..consts import CYRILLIC_CONVERSION_TABLE
13
+ from ..consts import NON_DIGIT_RE
14
+ from ..consts import NON_WORD_RE
15
+ from ..consts import REGISTRATION_NUMBER_RE
16
+ from ..consts import SPACES_RE
17
+ from ..gettext_lazy import _
18
+ from ..timezones import adjust_dt
19
+
20
+
21
+ class AsciiCharField(CharField):
22
+ default_error_messages = {
23
+ **CharField.default_error_messages,
24
+ "non_ascii": _("Ensure this field has only latin characters."),
25
+ }
26
+ # Allowed cyrillic characters
27
+ cyrillic_conversion_table = CYRILLIC_CONVERSION_TABLE
28
+ non_word_re = NON_WORD_RE
29
+ spaces_re = SPACES_RE
30
+
31
+ def __init__(self, *, uppercase=False, only_alphanumerics=False, normalize_spaces=True, translate_cyrillics=True, **kwargs):
32
+ super().__init__(**kwargs)
33
+ self.uppercase = uppercase
34
+ self.translate_cyrillics = translate_cyrillics
35
+ self.only_alphanumerics = only_alphanumerics
36
+ self.normalize_spaces = normalize_spaces
37
+
38
+ def to_internal_value(self, value):
39
+ value: str = super().to_internal_value(value)
40
+ if self.translate_cyrillics:
41
+ value = value.translate(self.cyrillic_conversion_table)
42
+ try:
43
+ value.encode("ascii")
44
+ except UnicodeEncodeError:
45
+ self.fail("non_ascii")
46
+ if self.only_alphanumerics:
47
+ value = self.non_word_re.sub("", value)
48
+ elif self.normalize_spaces:
49
+ value = self.spaces_re.sub(" ", value)
50
+ if self.uppercase:
51
+ value = value.upper()
52
+ return value
53
+
54
+
55
+ class DigitsCharField(AsciiCharField):
56
+ default_error_messages = {
57
+ **CharField.default_error_messages,
58
+ "non_digit": _("Ensure this field has only digits."),
59
+ }
60
+ non_word_re = NON_DIGIT_RE
61
+
62
+ def __init__(self, *, strip=True, **kwargs):
63
+ super().__init__(**kwargs, only_alphanumerics=strip)
64
+
65
+ def to_internal_value(self, value):
66
+ value: str = super().to_internal_value(value)
67
+ if not value.isdigit():
68
+ self.fail("non_digit")
69
+ return value
70
+
71
+
72
+ class RegistrationNumberField(AsciiCharField):
73
+ def __init__(self, **kwargs):
74
+ max_length = kwargs.pop("max_length", 10)
75
+ super().__init__(
76
+ **kwargs,
77
+ max_length=max_length,
78
+ only_alphanumerics=True,
79
+ uppercase=True,
80
+ )
81
+
82
+
83
+ class RomanianRegistrationNumberField(RegistrationNumberField):
84
+ def __init__(self, **kwargs):
85
+ super().__init__(
86
+ **kwargs,
87
+ validators=[
88
+ RegexValidator(
89
+ regex=REGISTRATION_NUMBER_RE["RO"],
90
+ message=_("Value must be a valid Romanian registration number."),
91
+ )
92
+ ],
93
+ )
94
+
95
+
96
+ class ChassisNumberField(AsciiCharField):
97
+ default_error_messages = {
98
+ **AsciiCharField.default_error_messages,
99
+ "length": _("Chassis number must have {required_length} characters ({length} given)."),
100
+ "letter": _("Chassis number cannot contain the letter '{letter}'."),
101
+ }
102
+
103
+ def __init__(self, required_length=17, **kwargs):
104
+ super().__init__(
105
+ **kwargs,
106
+ only_alphanumerics=True,
107
+ uppercase=True,
108
+ )
109
+ self.required_length = required_length
110
+
111
+ def to_internal_value(self, value):
112
+ value = super().to_internal_value(value)
113
+ length = len(value)
114
+ if self.required_length and length != self.required_length:
115
+ self.fail("length", length=length, required_length=self.required_length)
116
+ if "I" in value:
117
+ self.fail("letter", letter="i")
118
+ if "O" in value:
119
+ self.fail("letter", letter="o")
120
+ if "Q" in value:
121
+ self.fail("letter", letter="q")
122
+
123
+ return value
124
+
125
+
126
+ class EnumField(ChoiceField):
127
+ default_error_messages = {
128
+ "invalid_choice": _('"{input}" is not a valid choice. Must be one of: {opts}.'),
129
+ "invalid_choice_multiple": _('"{input}" is not a valid choice. Must be one of: {opts} or {last_opt}.'),
130
+ }
131
+
132
+ def __init__(self, *, enum: type[Choices], **kwargs):
133
+ super().__init__(
134
+ choices=[("", "-") if name == "__empty__" else (name, enum[name].label) for name in enum.names],
135
+ **kwargs,
136
+ )
137
+ self.enum = enum
138
+
139
+ def to_representation(self, value):
140
+ return self.enum(value).name
141
+
142
+ def to_internal_value(self, data):
143
+ if data in self.enum.names:
144
+ return self.enum[data]
145
+ else:
146
+ *opts, last_opt = self.enum
147
+ opts = ", ".join(f'"{i.name}"' for i in opts)
148
+ last_opt = f'"{last_opt.name}"'
149
+ if opts:
150
+ self.fail("invalid_choice_multiple", input=data, opts=opts, last_opt=last_opt)
151
+ else:
152
+ self.fail("invalid_choice", input=data, opts=last_opt)
153
+
154
+
155
+ class FormattedField(ReadOnlyField):
156
+ format: str
157
+
158
+ def __init__(self, *, format=None, **kwargs):
159
+ self.format = format
160
+ super().__init__(**kwargs, read_only=True)
161
+
162
+ def to_representation(self, value):
163
+ # noinspection StrFormat
164
+ return self.format.format(value)
165
+
166
+
167
+ class DispatchingHyperlinkedRelatedField(HyperlinkedRelatedField):
168
+ def __init__(self, *, dispatch_field, dispatch_mapping, **kwargs):
169
+ kwargs["read_only"] = True
170
+ kwargs["source"] = "*"
171
+ super().__init__(lookup_field=dispatch_field, view_name=dispatch_mapping, **kwargs)
172
+
173
+ def use_pk_only_optimization(self):
174
+ return False
175
+
176
+ def get_url(self, obj, view_name, request, format):
177
+ dispatch_value = getattr(obj, self.lookup_field)
178
+ view_options = view_name[dispatch_value]
179
+ lookup_value = getattr(obj, view_options["lookup_field"])
180
+ kwargs = {view_options["lookup_url_kwarg"]: lookup_value}
181
+ return self.reverse(view_options["view_name"], kwargs=kwargs, request=request, format=format)
182
+
183
+
184
+ class LocalizedTimeField(serializers.DateTimeField):
185
+ def __init__(self, *args, timezone: str | ZoneInfo = settings.TIME_ZONE, **kwargs):
186
+ self.timezone = ZoneInfo(timezone) if isinstance(timezone, str) else timezone
187
+ super().__init__(*args, **kwargs)
188
+
189
+ def to_representation(self, value):
190
+ if value is None:
191
+ return None
192
+
193
+ localized_value = adjust_dt(value, tz=self.timezone)
194
+ return localized_value.isoformat("T")
195
+
196
+
197
+ class PermissiveHyperlinkedRelatedField(serializers.HyperlinkedRelatedField):
198
+ def to_internal_value(self, data):
199
+ model = self.get_queryset().model
200
+ if isinstance(data, model):
201
+ return data
202
+ else:
203
+ return super().to_internal_value(data)
csu/drf/forms.py ADDED
@@ -0,0 +1,65 @@
1
+ from collections.abc import Callable
2
+ from typing import TypedDict
3
+ from typing import TypeVar
4
+
5
+ from django.core.exceptions import ValidationError
6
+ from django.forms import CharField
7
+ from rest_framework.exceptions import ValidationError as DRFValidationError
8
+ from rest_framework.fields import Field
9
+
10
+ try:
11
+ from typing import Unpack
12
+ except ImportError:
13
+ from typing_extensions import Unpack # noqa: UP035, RUF100
14
+
15
+ _FORM_FIELD_TYPE = TypeVar("_FORM_FIELD_TYPE", bound=type[Field])
16
+
17
+
18
+ class _DRF_FIELD_KWARGS(TypedDict):
19
+ read_only: bool
20
+ write_only: bool
21
+ required: bool | None
22
+ default: object
23
+ initial: object
24
+ source: str
25
+ label: str
26
+ help_text: str
27
+ style: str
28
+ error_messages: dict[str, str]
29
+ validators: list[Callable]
30
+ allow_null: bool
31
+
32
+
33
+ def formfield_for_drf_field(
34
+ drf_field: type[Field] | Field,
35
+ /,
36
+ *,
37
+ formfield_class: _FORM_FIELD_TYPE = CharField,
38
+ **drf_field_kwargs: Unpack[_DRF_FIELD_KWARGS],
39
+ ) -> _FORM_FIELD_TYPE:
40
+ if drf_field_kwargs:
41
+ assert issubclass(drf_field, Field)
42
+ drf_field = drf_field(**drf_field_kwargs)
43
+
44
+ class FormFieldWrapper(formfield_class):
45
+ def to_python(self, value):
46
+ value = super().to_python(value)
47
+ try:
48
+ return drf_field.run_validation(value)
49
+ except DRFValidationError as exc:
50
+ detail = exc.detail
51
+ if isinstance(detail, list):
52
+ if len(detail) == 1:
53
+ (detail,) = detail
54
+ raise ValidationError(str(detail), detail.code) from exc
55
+ else:
56
+ code = {err.code for err in detail}
57
+ if len(code) == 1:
58
+ (code,) = code
59
+ else:
60
+ code = None
61
+ raise ValidationError([str(err) for err in detail], code) from exc
62
+ else:
63
+ raise ValidationError(exc.detail) from exc
64
+
65
+ return FormFieldWrapper
csu/drf/phonenumber.py ADDED
@@ -0,0 +1,51 @@
1
+ from django.conf import settings
2
+ from phonenumber_field.phonenumber import PhoneNumber
3
+ from phonenumbers import NumberParseException
4
+ from phonenumbers import PhoneNumberType
5
+ from phonenumbers import number_type
6
+ from rest_framework import serializers
7
+
8
+ from ..gettext_lazy import _
9
+
10
+ ACCEPTABLE_PHONE_PREFIXES = {
11
+ 40, # Romania
12
+ }
13
+ NUMBER_TYPE_VALUES = PhoneNumberType.values()
14
+ NUMBER_TYPE_NAMES = {value: name for name, value in vars(PhoneNumberType).items() if value in NUMBER_TYPE_VALUES}
15
+
16
+
17
+ class PhoneNumberField(serializers.CharField):
18
+ default_error_messages = {
19
+ "invalid": _("Invalid phone number."),
20
+ "failed_parse": _("Invalid phone number: {error}."),
21
+ "wrong_type": _("Invalid phone number: type is {type}."),
22
+ "invalid_choice": _("'{country_code}' is not an accepted code."),
23
+ }
24
+
25
+ def to_internal_value(self, data):
26
+ phone_number: PhoneNumber | None = None
27
+ try:
28
+ phone_number = PhoneNumber.from_string(phone_number=data, region=settings.PHONENUMBER_DEFAULT_REGION)
29
+ except NumberParseException as exc:
30
+ if exc.error_type == NumberParseException.TOO_SHORT_NSN:
31
+ self.fail("failed_parse", error=_("too short to be a phone number"))
32
+ elif exc.error_type == NumberParseException.TOO_LONG:
33
+ self.fail("failed_parse", error=_("too long to be a phone number"))
34
+ else:
35
+ self.fail("invalid")
36
+
37
+ if phone_number and not phone_number.is_valid():
38
+ self.fail("invalid")
39
+
40
+ phone_number_type = number_type(phone_number)
41
+ if phone_number_type not in (
42
+ PhoneNumberType.MOBILE,
43
+ PhoneNumberType.FIXED_LINE_OR_MOBILE,
44
+ PhoneNumberType.PERSONAL_NUMBER,
45
+ PhoneNumberType.UNKNOWN,
46
+ ):
47
+ self.fail("wrong_type", type=NUMBER_TYPE_NAMES[phone_number_type])
48
+
49
+ if phone_number.country_code not in ACCEPTABLE_PHONE_PREFIXES:
50
+ self.fail("invalid_choice", country_code=phone_number.country_code)
51
+ return phone_number.as_e164