oxutils 0.1.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.
- oxutils/__init__.py +23 -0
- oxutils/apps.py +14 -0
- oxutils/audit/__init__.py +0 -0
- oxutils/audit/apps.py +12 -0
- oxutils/audit/export.py +229 -0
- oxutils/audit/masks.py +97 -0
- oxutils/audit/models.py +75 -0
- oxutils/audit/settings.py +19 -0
- oxutils/celery/__init__.py +1 -0
- oxutils/celery/base.py +98 -0
- oxutils/celery/settings.py +1 -0
- oxutils/conf.py +12 -0
- oxutils/enums/__init__.py +1 -0
- oxutils/enums/audit.py +8 -0
- oxutils/enums/invoices.py +11 -0
- oxutils/exceptions.py +117 -0
- oxutils/functions.py +99 -0
- oxutils/jwt/__init__.py +0 -0
- oxutils/jwt/auth.py +55 -0
- oxutils/jwt/client.py +123 -0
- oxutils/jwt/constants.py +1 -0
- oxutils/locale/fr/LC_MESSAGES/django.mo +0 -0
- oxutils/locale/fr/LC_MESSAGES/django.po +368 -0
- oxutils/logger/__init__.py +0 -0
- oxutils/logger/receivers.py +18 -0
- oxutils/logger/settings.py +63 -0
- oxutils/mixins/__init__.py +0 -0
- oxutils/mixins/base.py +21 -0
- oxutils/mixins/schemas.py +13 -0
- oxutils/mixins/services.py +146 -0
- oxutils/models/__init__.py +3 -0
- oxutils/models/base.py +116 -0
- oxutils/models/billing.py +140 -0
- oxutils/models/invoice.py +467 -0
- oxutils/py.typed +0 -0
- oxutils/s3/__init__.py +0 -0
- oxutils/s3/settings.py +34 -0
- oxutils/s3/storages.py +130 -0
- oxutils/settings.py +254 -0
- oxutils/types.py +13 -0
- oxutils-0.1.0.dist-info/METADATA +201 -0
- oxutils-0.1.0.dist-info/RECORD +43 -0
- oxutils-0.1.0.dist-info/WHEEL +4 -0
oxutils/exceptions.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from django.utils.translation import gettext_lazy as _
|
|
2
|
+
from oxutils.mixins.base import DetailDictMixin
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OxException(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from ninja_extra import status, exceptions
|
|
12
|
+
|
|
13
|
+
STATUS_CODE = status.HTTP_500_INTERNAL_SERVER_ERROR
|
|
14
|
+
NinjaException = exceptions.APIException
|
|
15
|
+
except:
|
|
16
|
+
STATUS_CODE = 500
|
|
17
|
+
NinjaException = OxException
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ExceptionCode:
|
|
21
|
+
INTERNAL_ERROR = 'internal_error'
|
|
22
|
+
FAILED_ERROR = 'failed_error'
|
|
23
|
+
VALIDATION_ERROR = 'validation_error'
|
|
24
|
+
CONFLICT_ERROR = 'conflict_error'
|
|
25
|
+
NOT_FOUND = 'not_found'
|
|
26
|
+
UNAUTHORIZED = 'unauthorized'
|
|
27
|
+
FORBIDDEN = 'forbidden'
|
|
28
|
+
BAD_REQUEST = 'bad_request'
|
|
29
|
+
AUTHENTICATION_FAILED = 'authentication_failed'
|
|
30
|
+
PERMISSION_DENIED = 'permission_denied'
|
|
31
|
+
METHOD_NOT_ALLOWED = 'method_not_allowed'
|
|
32
|
+
NOT_ACCEPTABLE = 'not_acceptable'
|
|
33
|
+
TIMEOUT = 'timeout'
|
|
34
|
+
RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded'
|
|
35
|
+
SERVICE_UNAVAILABLE = 'service_unavailable'
|
|
36
|
+
INVALID_TOKEN = 'invalid_token'
|
|
37
|
+
INVALID_ORGANIZATION_TOKEN = "invalid_org_token"
|
|
38
|
+
EXPIRED_TOKEN = 'expired_token'
|
|
39
|
+
INVALID_CREDENTIALS = 'invalid_credentials'
|
|
40
|
+
ACCOUNT_DISABLED = 'account_disabled'
|
|
41
|
+
ACCOUNT_NOT_VERIFIED = 'account_not_verified'
|
|
42
|
+
DUPLICATE_ENTRY = 'duplicate_entry'
|
|
43
|
+
RESOURCE_LOCKED = 'resource_locked'
|
|
44
|
+
PAYMENT_REQUIRED = 'payment_required'
|
|
45
|
+
INSUFFICIENT_PERMISSIONS = 'insufficient_permissions'
|
|
46
|
+
QUOTA_EXCEEDED = 'quota_exceeded'
|
|
47
|
+
INVALID_INPUT = 'invalid_input'
|
|
48
|
+
MISSING_PARAMETER = 'missing_parameter'
|
|
49
|
+
INVALID_PARAMETER = 'invalid_parameter'
|
|
50
|
+
OPERATION_NOT_PERMITTED = 'operation_not_permitted'
|
|
51
|
+
RESOURCE_EXHAUSTED = 'resource_exhausted'
|
|
52
|
+
PRECONDITION_FAILED = 'precondition_failed'
|
|
53
|
+
|
|
54
|
+
# Non Error
|
|
55
|
+
SUCCESS = 'success'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class APIException(DetailDictMixin, NinjaException):
|
|
60
|
+
status_code = STATUS_CODE
|
|
61
|
+
default_code = ExceptionCode.INTERNAL_ERROR
|
|
62
|
+
default_detail = _('We encountered an error, please try again later.')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NotFoundException(APIException):
|
|
66
|
+
status_code = 404
|
|
67
|
+
default_code = ExceptionCode.NOT_FOUND
|
|
68
|
+
default_detail = _('The requested resource does not exist')
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ValidationException(APIException):
|
|
72
|
+
status_code = 400
|
|
73
|
+
default_code = ExceptionCode.VALIDATION_ERROR
|
|
74
|
+
default_detail = _('Validation error')
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ConflictException(APIException):
|
|
78
|
+
status_code = 409
|
|
79
|
+
default_code = ExceptionCode.CONFLICT_ERROR
|
|
80
|
+
default_detail = _('The operation conflicts with existing data')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DuplicateEntryException(APIException):
|
|
84
|
+
status_code = 409
|
|
85
|
+
default_code = ExceptionCode.DUPLICATE_ENTRY
|
|
86
|
+
default_detail = _('A resource with these details already exists')
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PermissionDeniedException(APIException):
|
|
90
|
+
status_code = 403
|
|
91
|
+
default_code = ExceptionCode.PERMISSION_DENIED
|
|
92
|
+
default_detail = _('You do not have permission to perform this action')
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class UnauthorizedException(APIException):
|
|
96
|
+
status_code = 401
|
|
97
|
+
default_code = ExceptionCode.UNAUTHORIZED
|
|
98
|
+
default_detail = _('Authentication is required')
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class InvalidParameterException(APIException):
|
|
102
|
+
status_code = 400
|
|
103
|
+
default_code = ExceptionCode.INVALID_PARAMETER
|
|
104
|
+
default_detail = _('Invalid parameter provided')
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class MissingParameterException(APIException):
|
|
108
|
+
status_code = 400
|
|
109
|
+
default_code = ExceptionCode.MISSING_PARAMETER
|
|
110
|
+
default_detail = _('Required parameter is missing')
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class InternalErrorException(APIException):
|
|
114
|
+
status_code = 500
|
|
115
|
+
default_code = ExceptionCode.INTERNAL_ERROR
|
|
116
|
+
default_detail = _('An unexpected error occurred while processing your request')
|
|
117
|
+
|
oxutils/functions.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from urllib.parse import urljoin
|
|
3
|
+
from ninja.files import UploadedFile
|
|
4
|
+
from ninja_extra.exceptions import ValidationError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_absolute_url(url: str, request=None):
|
|
9
|
+
if request:
|
|
10
|
+
# Build absolute URL using request
|
|
11
|
+
return request.build_absolute_uri(url)
|
|
12
|
+
else:
|
|
13
|
+
# Fallback: build URL using MEDIA_URL and domain
|
|
14
|
+
base_url = getattr(settings, 'SITE_URL', 'http://localhost:8000')
|
|
15
|
+
return urljoin(base_url, url)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def request_is_bound(request):
|
|
19
|
+
"""
|
|
20
|
+
Check if a request is bound (has data), similar to Django Form.is_bound.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
if not request or not hasattr(request, 'method'):
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
if hasattr(request, 'data'):
|
|
27
|
+
return (
|
|
28
|
+
request.data is not None or
|
|
29
|
+
bool(getattr(request, 'FILES', {})) or
|
|
30
|
+
request.method in ('POST', 'PUT', 'PATCH')
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
elif hasattr(request, 'POST'):
|
|
34
|
+
return (
|
|
35
|
+
len(getattr(request, 'POST', {})) > 0 or
|
|
36
|
+
len(getattr(request, 'FILES', {})) > 0 or
|
|
37
|
+
request.method in ('POST', 'PUT', 'PATCH')
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_request_data(request):
|
|
44
|
+
"""
|
|
45
|
+
Extract data from request, similar to how Django Forms get their data.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
request: Django HttpRequest or DRF Request object
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
dict: Request data (empty dict if no data)
|
|
52
|
+
"""
|
|
53
|
+
if not request:
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
# DRF Request
|
|
57
|
+
if hasattr(request, 'data'):
|
|
58
|
+
return request.data or {}
|
|
59
|
+
|
|
60
|
+
# Django HttpRequest
|
|
61
|
+
elif hasattr(request, 'POST'):
|
|
62
|
+
return dict(request.POST) if request.POST else {}
|
|
63
|
+
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_image(image: UploadedFile, size: int = 2):
|
|
68
|
+
"""Validate uploaded image file"""
|
|
69
|
+
# Check file size (2MB = 2 * 1024 * 1024 bytes)
|
|
70
|
+
MAX_FILE_SIZE = size * 1024 * 1024 # 2MB
|
|
71
|
+
if image.size > MAX_FILE_SIZE:
|
|
72
|
+
raise ValidationError(f"La taille du fichier ne peut pas dépasser 2MB. Taille actuelle: {image.size / (1024 * 1024):.1f}MB")
|
|
73
|
+
|
|
74
|
+
# Check file type
|
|
75
|
+
ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']
|
|
76
|
+
if image.content_type not in ALLOWED_TYPES:
|
|
77
|
+
raise ValidationError(f"Type de fichier non supporté. Types autorisés: {', '.join(ALLOWED_TYPES)}")
|
|
78
|
+
|
|
79
|
+
# Check file extension
|
|
80
|
+
ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
|
81
|
+
file_extension = image.name.lower().split('.')[-1] if '.' in image.name else ''
|
|
82
|
+
if f'.{file_extension}' not in ALLOWED_EXTENSIONS:
|
|
83
|
+
raise ValidationError(f"Extension de fichier non supportée. Extensions autorisées: {', '.join(ALLOWED_EXTENSIONS)}")
|
|
84
|
+
|
|
85
|
+
# Additional validation: check if file is actually an image
|
|
86
|
+
try:
|
|
87
|
+
from PIL import Image
|
|
88
|
+
import io
|
|
89
|
+
|
|
90
|
+
# Reset file pointer to beginning
|
|
91
|
+
image.seek(0)
|
|
92
|
+
image = Image.open(io.BytesIO(image.read()))
|
|
93
|
+
image.verify() # Verify it's a valid image
|
|
94
|
+
|
|
95
|
+
# Reset file pointer again for later use
|
|
96
|
+
image.seek(0)
|
|
97
|
+
|
|
98
|
+
except Exception as e:
|
|
99
|
+
raise ValidationError("Le fichier n'est pas une image valide")
|
oxutils/jwt/__init__.py
ADDED
|
File without changes
|
oxutils/jwt/auth.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Dict, Any, Optional
|
|
3
|
+
from jwcrypto import jwk
|
|
4
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
5
|
+
from oxutils.settings import oxi_settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_public_jwk_cache: Optional[jwk.JWK] = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_jwks() -> Dict[str, Any]:
|
|
14
|
+
"""
|
|
15
|
+
Get JSON Web Key Set (JWKS) for JWT verification.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Dict containing the public JWK in JWKS format.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
ImproperlyConfigured: If jwt_verifying_key is not configured or file doesn't exist.
|
|
22
|
+
"""
|
|
23
|
+
global _public_jwk_cache
|
|
24
|
+
|
|
25
|
+
if oxi_settings.jwt_verifying_key is None:
|
|
26
|
+
raise ImproperlyConfigured(
|
|
27
|
+
"JWT verifying key is not configured. Set OXI_JWT_VERIFYING_KEY environment variable."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
key_path = oxi_settings.jwt_verifying_key
|
|
31
|
+
|
|
32
|
+
if not os.path.exists(key_path):
|
|
33
|
+
raise ImproperlyConfigured(
|
|
34
|
+
f"JWT verifying key file not found at: {key_path}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if _public_jwk_cache is None:
|
|
38
|
+
try:
|
|
39
|
+
with open(key_path, 'r') as f:
|
|
40
|
+
key_data = f.read()
|
|
41
|
+
|
|
42
|
+
_public_jwk_cache = jwk.JWK.from_pem(key_data.encode('utf-8'))
|
|
43
|
+
_public_jwk_cache.update(kid='main')
|
|
44
|
+
except Exception as e:
|
|
45
|
+
raise ImproperlyConfigured(
|
|
46
|
+
f"Failed to load JWT verifying key from {key_path}: {str(e)}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return {"keys": [_public_jwk_cache.export(as_dict=True)]}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def clear_jwk_cache() -> None:
|
|
53
|
+
"""Clear the cached JWK. Useful for testing or key rotation."""
|
|
54
|
+
global _public_jwk_cache
|
|
55
|
+
_public_jwk_cache = None
|
oxutils/jwt/client.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import jwt
|
|
2
|
+
import requests
|
|
3
|
+
from typing import Dict, Any, Optional
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
6
|
+
from oxutils.settings import oxi_settings
|
|
7
|
+
from .constants import JWT_ALGORITHM
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_jwks_cache: Optional[Dict[str, Any]] = None
|
|
11
|
+
_jwks_cache_time: Optional[datetime] = None
|
|
12
|
+
_jwks_cache_ttl = timedelta(hours=1)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_jwks_url() -> str:
|
|
16
|
+
"""
|
|
17
|
+
Get JWKS URL from settings.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The configured JWKS URL.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
ImproperlyConfigured: If jwt_jwks_url is not configured.
|
|
24
|
+
"""
|
|
25
|
+
if not oxi_settings.jwt_jwks_url:
|
|
26
|
+
raise ImproperlyConfigured(
|
|
27
|
+
"JWT JWKS URL is not configured. Set OXI_JWT_JWKS_URL environment variable."
|
|
28
|
+
)
|
|
29
|
+
return oxi_settings.jwt_jwks_url
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fetch_jwks(force_refresh: bool = False) -> Dict[str, Any]:
|
|
33
|
+
"""
|
|
34
|
+
Fetch JWKS from the authentication server with caching.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
force_refresh: Force refresh the cache even if not expired.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Dict containing the JWKS.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ImproperlyConfigured: If JWKS cannot be fetched.
|
|
44
|
+
"""
|
|
45
|
+
global _jwks_cache, _jwks_cache_time
|
|
46
|
+
|
|
47
|
+
now = datetime.now()
|
|
48
|
+
|
|
49
|
+
# Return cached JWKS if valid
|
|
50
|
+
if not force_refresh and _jwks_cache is not None and _jwks_cache_time is not None:
|
|
51
|
+
if now - _jwks_cache_time < _jwks_cache_ttl:
|
|
52
|
+
return _jwks_cache
|
|
53
|
+
|
|
54
|
+
# Fetch fresh JWKS
|
|
55
|
+
jwks_url = get_jwks_url()
|
|
56
|
+
try:
|
|
57
|
+
response = requests.get(jwks_url, timeout=10)
|
|
58
|
+
response.raise_for_status()
|
|
59
|
+
_jwks_cache = response.json()
|
|
60
|
+
_jwks_cache_time = now
|
|
61
|
+
return _jwks_cache
|
|
62
|
+
except requests.RequestException as e:
|
|
63
|
+
raise ImproperlyConfigured(
|
|
64
|
+
f"Failed to fetch JWKS from {jwks_url}: {str(e)}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_key(kid: str):
|
|
69
|
+
"""
|
|
70
|
+
Get the public key for a given Key ID (kid).
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
kid: The Key ID from the JWT header.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
RSA public key for verification.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ValueError: If the kid is not found in JWKS.
|
|
80
|
+
"""
|
|
81
|
+
jwks = fetch_jwks()
|
|
82
|
+
|
|
83
|
+
for key in jwks.get("keys", []):
|
|
84
|
+
if key.get("kid") == kid:
|
|
85
|
+
return jwt.algorithms.RSAAlgorithm.from_jwk(key)
|
|
86
|
+
|
|
87
|
+
raise ValueError(f"Unknown Key ID (kid): {kid}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def verify_token(token: str) -> Dict[str, Any]:
|
|
91
|
+
"""
|
|
92
|
+
Verify and decode a JWT token.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
token: The JWT token string to verify.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Dict containing the decoded token payload.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
jwt.InvalidTokenError: If token is invalid or expired.
|
|
102
|
+
ValueError: If kid is not found.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
headers = jwt.get_unverified_header(token)
|
|
106
|
+
kid = headers.get("kid")
|
|
107
|
+
|
|
108
|
+
if not kid:
|
|
109
|
+
raise ValueError("Token header missing 'kid' field")
|
|
110
|
+
|
|
111
|
+
key = get_key(kid)
|
|
112
|
+
return jwt.decode(token, key=key, algorithms=JWT_ALGORITHM)
|
|
113
|
+
except jwt.InvalidTokenError:
|
|
114
|
+
raise
|
|
115
|
+
except Exception as e:
|
|
116
|
+
raise jwt.InvalidTokenError(f"Token verification failed: {str(e)}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def clear_jwks_cache() -> None:
|
|
120
|
+
"""Clear the cached JWKS. Useful for testing or key rotation."""
|
|
121
|
+
global _jwks_cache, _jwks_cache_time
|
|
122
|
+
_jwks_cache = None
|
|
123
|
+
_jwks_cache_time = None
|
oxutils/jwt/constants.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
JWT_ALGORITHM = ["RS256"]
|
|
Binary file
|