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 +5 -0
- csu/admin.py +116 -0
- csu/conf.py +19 -0
- csu/consts.py +70 -0
- csu/drf/__init__.py +0 -0
- csu/drf/auth.py +71 -0
- csu/drf/fields.py +203 -0
- csu/drf/forms.py +65 -0
- csu/drf/phonenumber.py +51 -0
- csu/drf/serializers.py +108 -0
- csu/drf/test_fields.py +149 -0
- csu/drf/test_forms.py +62 -0
- csu/drf/test_phonenumber.py +43 -0
- csu/drf/views.py +63 -0
- csu/enums.py +11 -0
- csu/env.py +43 -0
- csu/exceptions.py +99 -0
- csu/fixups.py +4 -0
- csu/forms.py +35 -0
- csu/gettext.py +9 -0
- csu/gettext_lazy.py +9 -0
- csu/locale/ro/LC_MESSAGES/django.mo +0 -0
- csu/locale/ro/LC_MESSAGES/django.po +150 -0
- csu/logging.py +108 -0
- csu/models.py +110 -0
- csu/routers.py +15 -0
- csu/service.py +315 -0
- csu/templates/api_exception.html +20 -0
- csu/test_consts.py +22 -0
- csu/test_service.py +10 -0
- csu/test_timezones.py +40 -0
- csu/test_utils.py +60 -0
- csu/test_xml.py +163 -0
- csu/timezones.py +80 -0
- csu/utils.py +63 -0
- csu/views.py +154 -0
- csu/worker/__init__.py +3 -0
- csu/worker/admin.py +181 -0
- csu/worker/asgi.py +36 -0
- csu/worker/engine.py +284 -0
- csu/worker/enums.py +13 -0
- csu/worker/job.py +18 -0
- csu/worker/models.py +172 -0
- csu/worker/registry.py +22 -0
- csu/wsgi.py +40 -0
- csu/xml.py +155 -0
- csu-1.0.0.dist-info/AUTHORS.rst +5 -0
- csu-1.0.0.dist-info/LICENSE +10 -0
- csu-1.0.0.dist-info/METADATA +100 -0
- csu-1.0.0.dist-info/RECORD +52 -0
- csu-1.0.0.dist-info/WHEEL +5 -0
- csu-1.0.0.dist-info/top_level.txt +1 -0
csu/__init__.py
ADDED
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
|