entirius-django-utils 2.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.
File without changes
@@ -0,0 +1,23 @@
1
+ from django_utils.settings import ADMIN_THEME
2
+
3
+ if ADMIN_THEME == "unfold":
4
+ from django.contrib import admin
5
+ from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
6
+ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
7
+ from django.contrib.auth.models import Group, User
8
+ from unfold.admin import ModelAdmin
9
+ from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm
10
+
11
+ admin.site.unregister(User)
12
+ admin.site.unregister(Group)
13
+
14
+ @admin.register(User)
15
+ class UserAdmin(BaseUserAdmin, ModelAdmin):
16
+ # Forms loaded from `unfold.forms`
17
+ form = UserChangeForm
18
+ add_form = UserCreationForm
19
+ change_password_form = AdminPasswordChangeForm
20
+
21
+ @admin.register(Group)
22
+ class GroupAdmin(BaseGroupAdmin, ModelAdmin):
23
+ pass
@@ -0,0 +1,31 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+
5
+ import logging
6
+
7
+ from django_utils.settings import ADMIN_THEME
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ match ADMIN_THEME:
12
+ case "unfold":
13
+ try:
14
+ from unfold.admin import ModelAdmin, StackedInline, TabularInline
15
+ except ImportError:
16
+ logger.error("Unfold admin theme is not installed, run: pip install django-unfold")
17
+ from django.contrib.admin import ModelAdmin, StackedInline, TabularInline
18
+ case _:
19
+ from django.contrib.admin import ModelAdmin, StackedInline, TabularInline
20
+
21
+
22
+ class BaseModelAdmin(ModelAdmin):
23
+ pass
24
+
25
+
26
+ class BaseStackedInline(StackedInline):
27
+ pass
28
+
29
+
30
+ class BaseTabularInline(TabularInline):
31
+ pass
File without changes
@@ -0,0 +1,47 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+
5
+
6
+ from .errors import ErrorInfo
7
+
8
+
9
+ class BaseResponse:
10
+ errors = None
11
+
12
+ def __init__(self, errors: list[ErrorInfo] = None):
13
+ self.errors = errors
14
+
15
+ if errors is None:
16
+ self.errors = []
17
+
18
+ def add_error(
19
+ self,
20
+ message: str,
21
+ code: str,
22
+ affected_values: list[str] | None = None,
23
+ affected_field: str | None = None,
24
+ extra: dict | None = None,
25
+ ):
26
+ if code in [error.code for error in self.errors]:
27
+ for error in self.errors:
28
+ if error.code == code:
29
+ if affected_values:
30
+ error.affected_values = list(set(error.affected_values + affected_values))
31
+ break
32
+ else:
33
+ self.errors.append(ErrorInfo(message, code, affected_values, affected_field, extra))
34
+ return self
35
+
36
+ def add_errors(self, errors: list[ErrorInfo | dict]):
37
+ for error in errors:
38
+ if isinstance(error, ErrorInfo):
39
+ self.add_error(error.message, error.code, error.affected_values, error.affected_field, error.extra)
40
+ elif isinstance(error, dict):
41
+ message = error.get("message", None)
42
+ code = error.get("code", None)
43
+ if isinstance(message, str) and isinstance(code, str):
44
+ self.add_error(message, code, error.get("affected_values", None))
45
+ else:
46
+ continue
47
+ return self
@@ -0,0 +1,189 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+
5
+ import ipaddress
6
+ import logging
7
+ from functools import partial, wraps
8
+
9
+ from django.contrib import auth
10
+ from django.core.exceptions import ObjectDoesNotExist
11
+ from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
12
+ from django_regional.models import Country
13
+ from webargs.djangoparser import use_args
14
+
15
+ from django_utils import settings
16
+ from django_utils.api.exceptions import JWTException
17
+
18
+ from .exceptions import MethodNotAllowed, Unauthorized, handle_exception
19
+ from .responses import PaginatedResponse, Response, to_json_response
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def api_view(view):
25
+ """
26
+ Wraps view function or method on a class based view,
27
+ enforcing standard response formatting and exception handling.
28
+
29
+ Decorated function must return a Response object.
30
+ Response class can be found in helpers/response.py
31
+ """
32
+
33
+ @wraps(view)
34
+ def _wrapped(request, *args, **kwargs):
35
+ try:
36
+ response = view(request, *args, **kwargs)
37
+ except Exception as e:
38
+ response = handle_exception(e, request)
39
+ finally:
40
+ if isinstance(response, (Response, PaginatedResponse)):
41
+ return to_json_response(response)
42
+ elif isinstance(response, HttpResponseRedirect):
43
+ return response
44
+ elif isinstance(response, HttpResponse):
45
+ return response
46
+ else:
47
+ JsonResponse({"error": "view returned unsupported response type"})
48
+
49
+ return _wrapped
50
+
51
+
52
+ def authenticate_optional(view):
53
+ @wraps(view)
54
+ def _wrapped(request, *args, **kwargs):
55
+ try:
56
+ request.user = auth.authenticate(request)
57
+ except JWTException:
58
+ request.user = None
59
+ pass
60
+
61
+ response = view(request, *args, **kwargs)
62
+ return response
63
+
64
+ return _wrapped
65
+
66
+
67
+ def authenticate(view):
68
+ @wraps(view)
69
+ def _wrapped(request, *args, **kwargs):
70
+ request.user = auth.authenticate(request)
71
+ request.customer = request.user.customer if request.user is not None else None
72
+ response = view(request, *args, **kwargs)
73
+ return response
74
+
75
+ return _wrapped
76
+
77
+
78
+ def require_authentication(view):
79
+ @wraps(view)
80
+ def _wrapped(request, *args, **kwargs):
81
+ user = request.user
82
+ passed = user is not None and user.is_authenticated
83
+ if passed:
84
+ result = view(request, *args, **kwargs)
85
+ return result
86
+ else:
87
+ raise Unauthorized
88
+
89
+ return _wrapped
90
+
91
+
92
+ def require_http_method(*methods):
93
+ def _wrapper(view):
94
+ @wraps(view)
95
+ def _wrapped(request, *args, **kwargs):
96
+ passed = request.method in methods
97
+ if passed:
98
+ result = view(request, *args, **kwargs)
99
+ return result
100
+ else:
101
+ raise MethodNotAllowed
102
+
103
+ return _wrapped
104
+
105
+ return _wrapper
106
+
107
+
108
+ def ip_getter(view_func):
109
+ """
110
+ Dekorator, który pobiera adres IP z nagłówka i zapisuje go do request.ip.
111
+ Domyślnie sprawdza nagłówek X-Forwarded-For, a następnie X-Real-IP.
112
+ Jeśli nie ma tych nagłówków, używa request.META.get('REMOTE_ADDR').
113
+ """
114
+
115
+ @wraps(view_func)
116
+ def _wrapped_view(request, *args, **kwargs):
117
+ # Próba pobrania IP z różnych nagłówków HTTP
118
+ if (last_session_ip := request.headers.get(settings.HEADER_IP_ADDRESS, None)) is not None:
119
+ try:
120
+ ipaddress.ip_address(last_session_ip)
121
+ request.last_session_ip = last_session_ip
122
+ except ValueError:
123
+ logger.warning(f"IP address {last_session_ip} is not valid")
124
+
125
+ response = view_func(request, *args, **kwargs)
126
+ return response
127
+
128
+ return _wrapped_view
129
+
130
+
131
+ def save_ip_and_country(view_func):
132
+ """
133
+
134
+ request needs to have headers with keys:
135
+ - settings.HEADER_IP_ADDRESS
136
+ - settings.HEADER_COUNTRY
137
+
138
+ and customer object in request.customer
139
+
140
+ """
141
+
142
+ @wraps(view_func)
143
+ def _wrapped_view(request, *args, **kwargs):
144
+ response = view_func(request, *args, **kwargs)
145
+
146
+ customer = request.customer
147
+ if not customer:
148
+ return response
149
+
150
+ if (last_session_ip := request.headers.get(settings.HEADER_IP_ADDRESS, None)) is not None:
151
+ try:
152
+ ipaddress.ip_address(last_session_ip)
153
+ customer.last_session_ip = last_session_ip
154
+ except ValueError:
155
+ logger.warning(f"IP address {last_session_ip} for customer {customer.uid} is not valid")
156
+
157
+ if (last_session_country := request.headers.get(settings.HEADER_COUNTRY, None)) is not None:
158
+ try:
159
+ country = Country.objects.get(iso2=last_session_country)
160
+ customer.last_session_country = country
161
+ except ObjectDoesNotExist:
162
+ logger.warning(f"Country {last_session_country} for customer {customer.uid} is not valid")
163
+ try:
164
+ customer.save()
165
+ except Exception as e:
166
+ logger.error(f"Failed to save customer {customer.uid} last session data: {e}")
167
+ return response
168
+
169
+ return _wrapped_view
170
+
171
+
172
+ def verify_captcha(view_func):
173
+ try:
174
+ from django_captcha.output.decorators import verify_recaptcha
175
+ except ImportError:
176
+ return view_func
177
+
178
+ return verify_recaptcha(view_func)
179
+
180
+
181
+ # Uses webargs django parser
182
+ # BASICS https://webargs.readthedocs.io/en/latest/quickstart.html#basic-usage
183
+ # MODULE API https://webargs.readthedocs.io/en/latest/api.html#module-webargs.djangoparser
184
+ # HOW TO USE WHAT'S BELOW https://webargs.readthedocs.io/en/latest/advanced.html#mixing-locations
185
+ # HOW TO USE WITH DATACLASSES https://github.com/lovasoa/marshmallow_dataclass
186
+ # TODO Think about autodocs with apispec and apispec-decorator-crawler
187
+ parse_body = partial(use_args, location="json")
188
+ parse_form = partial(use_args, location="form")
189
+ parse_parameters = partial(use_args, location="query")
@@ -0,0 +1,24 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class ErrorInfo:
10
+ message: str
11
+ code: str
12
+ affected_values: list[str] | None = None
13
+ affected_field: str | None = None
14
+ extra: dict | None = None
15
+
16
+ def to_dict(self):
17
+ result = {"message": self.message, "code": self.code}
18
+ if self.affected_values:
19
+ result["affected_values"] = self.affected_values
20
+ if self.affected_field:
21
+ result["affected_field"] = self.affected_field
22
+ if self.extra:
23
+ result["extra"] = self.extra
24
+ return result
@@ -0,0 +1,132 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+
5
+ import logging
6
+
7
+ from django.http import HttpRequest
8
+ from marshmallow.exceptions import MarshmallowError
9
+ from marshmallow.exceptions import ValidationError as MarshmallowValidationError
10
+
11
+ from ..settings import LOG_HTTP_CODE_GTE
12
+ from .base_response import BaseResponse
13
+ from .errors import ErrorInfo
14
+ from .responses import Response
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class APIException(BaseResponse, Exception):
20
+ data: dict = {}
21
+ message: str = "Error"
22
+ status: str = "ERR"
23
+ status_code: int = 500
24
+
25
+ def __init__(self, data=None, message=None, status=None, errors: list[ErrorInfo] = None) -> None:
26
+ if errors is None:
27
+ errors = []
28
+ super().__init__(errors)
29
+ self.data = self.data if data is None else data
30
+ self.message = self.message if message is None else message
31
+ self.status = self.status if status is None else status
32
+
33
+ def __str__(self) -> str:
34
+ return f"{self.status}: {self.message}"
35
+
36
+
37
+ class BadRequest(APIException):
38
+ message = "Invalid request"
39
+ status = "BAD_REQUEST"
40
+ status_code = 400
41
+
42
+
43
+ class EmailTaken(BadRequest):
44
+ message = "Email already in use"
45
+ status = "EMAIL_TAKEN"
46
+
47
+
48
+ class ValidationError(BadRequest):
49
+ message = "Validation Error"
50
+
51
+
52
+ class Unauthorized(APIException):
53
+ message = "Requires authentication"
54
+ status = "UNAUTHORIZED"
55
+ status_code = 401
56
+
57
+
58
+ class JWTException(Unauthorized):
59
+ def __init__(self, message, status, code):
60
+ self.message = message
61
+ self.status = status
62
+ self.code = code
63
+
64
+
65
+ class Forbidden(APIException):
66
+ message = "Permission denied"
67
+ status = "FORBIDDEN"
68
+ status_code = 403
69
+
70
+
71
+ class NotFound(APIException):
72
+ message = "Resource not found"
73
+ status = "NOT_FOUND"
74
+ status_code = 404
75
+
76
+
77
+ class MethodNotAllowed(APIException):
78
+ message = "Invalid http method"
79
+ status = "METHOD_NOT_ALLOWED"
80
+ status_code = 405
81
+
82
+
83
+ def handle_django_exception(e): ...
84
+
85
+
86
+ def handle_marshmallow_exception(e):
87
+ def normalize(e):
88
+ if isinstance(e, MarshmallowValidationError):
89
+ return normalize(e.normalized_messages())
90
+ elif isinstance(e, dict):
91
+ return {key: normalize(elem) for key, elem in e.items()}
92
+ elif isinstance(e, list):
93
+ return [normalize(elem) for elem in e]
94
+ else:
95
+ return e
96
+
97
+ if isinstance(e, MarshmallowValidationError):
98
+ status = "BAD_REQUEST"
99
+ status_code = 400
100
+ message = "Validation error"
101
+ data = normalize(e)
102
+ else:
103
+ status = "ERR"
104
+ status_code = 500
105
+ message = "Unhandeld Validaion Exception"
106
+ data = {}
107
+
108
+ return status, status_code, message, data
109
+
110
+
111
+ def handle_exception(e: Exception, request: HttpRequest):
112
+ if isinstance(e, APIException):
113
+ status = e.status
114
+ status_code = e.status_code
115
+ message = e.message
116
+ data = e.data
117
+ errors = e.errors
118
+ elif isinstance(e, MarshmallowError):
119
+ errors = []
120
+ status, status_code, message, data = handle_marshmallow_exception(e)
121
+ else:
122
+ errors = []
123
+ status = "ERR"
124
+ status_code = 500
125
+ message = "Internal Exception"
126
+ data = {}
127
+
128
+ if status_code >= LOG_HTTP_CODE_GTE:
129
+ # logger.exception is better for sentry than logger.error
130
+ logger.exception(e)
131
+
132
+ return Response(status=status, status_code=status_code, message=message, data=data, errors=errors)
@@ -0,0 +1,20 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+
5
+ from typing import Any
6
+
7
+ from django.db.models import QuerySet
8
+ from django_filters.filterset import FilterSetMetaclass
9
+
10
+
11
+ def filter_qs(filterset: FilterSetMetaclass, params: dict, queryset: QuerySet, request=None) -> tuple[QuerySet, Any]:
12
+ """
13
+ Filters queryset according to request params
14
+
15
+ Ex:
16
+ qs = Model.objects.all()
17
+ filtered_qs = filter_queryset(ModelFilter, request.GET, qs)
18
+ """
19
+ filter_obj = filterset(params, request=request, queryset=queryset)
20
+ return filter_obj.qs, filter_obj
@@ -0,0 +1,37 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+
5
+
6
+ from django.core.paginator import Paginator
7
+ from django.db.models import QuerySet
8
+
9
+ from .exceptions import BadRequest
10
+
11
+
12
+ def paginate_qs(params: dict, objs_list: QuerySet) -> tuple[dict, QuerySet]:
13
+ """
14
+ Paginates list or queryset according to request params
15
+
16
+ Ex:
17
+ qs = Model.objects.all()
18
+ pagination, paginated_qs = paginate(request.GET, qs)
19
+ """
20
+ page_param = params.get("page", None)
21
+ which_page = int(page_param) if page_param is not None else 1
22
+ limit_param = params.get("limit", None)
23
+ objs_per_page = int(limit_param) if limit_param is not None else 10
24
+ if objs_per_page > 100:
25
+ raise BadRequest(message="Requesting more than 100 records per request is not allowed")
26
+ else:
27
+ paginator = Paginator(objs_list, objs_per_page)
28
+ pages = paginator.num_pages
29
+ records = paginator.count
30
+ pagination_dict = {
31
+ "page": which_page,
32
+ "limit": objs_per_page,
33
+ "pages": pages,
34
+ "records": records,
35
+ }
36
+ page = paginator.page(which_page)
37
+ return pagination_dict, page.object_list
@@ -0,0 +1,80 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+
5
+ from typing import Any
6
+
7
+ from django.http import JsonResponse
8
+
9
+ from .base_response import BaseResponse
10
+ from .errors import ErrorInfo
11
+
12
+
13
+ class Response(BaseResponse):
14
+ language = None
15
+ currency = None
16
+ country = None
17
+
18
+ def __init__(
19
+ self,
20
+ data: Any,
21
+ status: str = "OK",
22
+ message: str = "",
23
+ status_code: int = 200,
24
+ messages: list[str] | None = None,
25
+ errors: list[ErrorInfo] = None,
26
+ ):
27
+ super().__init__(errors)
28
+ self.data = data
29
+ self.status = status
30
+ self.message = message
31
+ self.status_code = status_code
32
+
33
+ if messages is None:
34
+ messages = []
35
+
36
+ self.messages = messages
37
+ if message == "" and len(messages) > 0:
38
+ self.message = messages[0]
39
+
40
+ def add_regional(self, language: str | None = None, currency: str | None = None, country: str | None = None):
41
+ self.language = language
42
+ self.currency = currency
43
+ self.country = country
44
+ return self
45
+
46
+
47
+ class PaginatedResponse(Response):
48
+ def __init__(
49
+ self,
50
+ pagination: dict,
51
+ data: list,
52
+ status: str = "OK",
53
+ message: str = "",
54
+ status_code: int = 200,
55
+ messages: list[str] | None = None,
56
+ ):
57
+ super().__init__(data, status, message, status_code, messages)
58
+ self.pagination = pagination
59
+
60
+
61
+ def to_json_response(res: Response):
62
+ base = {
63
+ "meta": {
64
+ "status": res.status,
65
+ "message": res.message,
66
+ "messages": res.messages,
67
+ "errors": [error.to_dict() for error in res.errors],
68
+ "regional": {"language": res.language, "currency": res.currency, "country": res.country},
69
+ },
70
+ "data": res.data,
71
+ }
72
+
73
+ if isinstance(res, PaginatedResponse):
74
+ body = dict(**base, pagination=res.pagination)
75
+ else:
76
+ body = base
77
+
78
+ response = JsonResponse(body)
79
+ response.status_code = res.status_code
80
+ return response
@@ -0,0 +1,15 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+
5
+
6
+ from django_utils import settings
7
+
8
+
9
+ # Need to have it here, otherwise django will iterate over a queryset
10
+ def make_media_url(media_path: str) -> str | None:
11
+ media_url = getattr(settings, "MEDIA_URL", "")
12
+ if media_path is not None:
13
+ return "".join([media_url, media_path])
14
+ else:
15
+ return None