oxutils 0.1.6__py3-none-any.whl → 0.1.8__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 CHANGED
@@ -10,7 +10,7 @@ This package provides:
10
10
  - Custom exceptions
11
11
  """
12
12
 
13
- __version__ = "0.1.6"
13
+ __version__ = "0.1.8"
14
14
 
15
15
  from oxutils.settings import oxi_settings
16
16
  from oxutils.conf import UTILS_APPS, AUDIT_MIDDLEWARE
oxutils/constants.py CHANGED
@@ -1,2 +1,4 @@
1
1
  ORGANIZATION_QUERY_KEY = 'organization_id'
2
2
  ORGANIZATION_HEADER_KEY = 'X-Organization-ID'
3
+ ORGANIZATION_TOKEN_COOKIE_KEY = 'organization_token'
4
+ OXILIERE_SERVICE_TOKEN = 'X-Oxiliere-Token'
oxutils/jwt/models.py ADDED
@@ -0,0 +1,68 @@
1
+ from uuid import UUID
2
+ from django.utils.functional import cached_property
3
+ from ninja_jwt.models import TokenUser as DefaultTonkenUser
4
+ from ninja_jwt.settings import api_settings
5
+
6
+ import structlog
7
+ from .tokens import OrganizationAccessToken
8
+
9
+
10
+
11
+ logger = structlog.get_logger(__name__)
12
+
13
+
14
+ class TokenTenant:
15
+
16
+ def __init__(
17
+ self,
18
+ schema_name: str,
19
+ tenant_id: int,
20
+ oxi_id: str,
21
+ subscription_plan: str,
22
+ subscription_status: str,
23
+ status: str,
24
+ ):
25
+ self.schema_name = schema_name
26
+ self.id = tenant_id
27
+ self.oxi_id = oxi_id
28
+ self.subscription_plan = subscription_plan
29
+ self.subscription_status = subscription_status
30
+ self.status = status
31
+
32
+ def __str__(self):
33
+ return f"{self.schema_name} - {self.oxi_id}"
34
+
35
+ @property
36
+ def pk(self):
37
+ return self.id
38
+
39
+ @classmethod
40
+ def for_token(cls, token):
41
+ try:
42
+ token_obj = OrganizationAccessToken(token=token)
43
+ tenant = cls(
44
+ schema_name=token_obj['schema_name'],
45
+ tenant_id=token_obj['tenant_id'],
46
+ oxi_id=token_obj['oxi_id'],
47
+ subscription_plan=token_obj['subscription_plan'],
48
+ subscription_status=token_obj['subscription_status'],
49
+ status=token_obj['status'],
50
+ )
51
+ return tenant
52
+ except Exception:
53
+ logger.exception('Failed to create TokenTenant from token', token=token)
54
+ return None
55
+
56
+
57
+ class TokenUser(DefaultTonkenUser):
58
+ @cached_property
59
+ def id(self):
60
+ return UUID(self.token[api_settings.USER_ID_CLAIM])
61
+
62
+ @cached_property
63
+ def token_created_at(self):
64
+ return self.token.get('cat', None)
65
+
66
+ @cached_property
67
+ def token_session(self):
68
+ return self.token.get('session', None)
oxutils/jwt/tokens.py ADDED
@@ -0,0 +1,69 @@
1
+ from django.conf import settings
2
+ from ninja_jwt.tokens import Token, AccessToken
3
+ from ninja_jwt.backends import TokenBackend
4
+ from ninja_jwt.settings import api_settings
5
+ from datetime import timedelta
6
+ from oxutils.settings import oxi_settings
7
+
8
+
9
+
10
+
11
+ __all__ = [
12
+ 'AccessToken',
13
+ 'OrganizationAccessToken',
14
+ ]
15
+
16
+
17
+
18
+ token_backend = TokenBackend(
19
+ algorithm='HS256',
20
+ signing_key=settings.SECRET_KEY,
21
+ verifying_key="",
22
+ audience=None,
23
+ issuer=None,
24
+ jwk_url=None,
25
+ leeway=api_settings.LEEWAY,
26
+ json_encoder=api_settings.JSON_ENCODER,
27
+ )
28
+
29
+
30
+
31
+ class OxilierServiceToken(Token):
32
+ token_type = oxi_settings.jwt_service_token_key
33
+ lifetime = timedelta(minutes=oxi_settings.jwt_service_token_lifetime)
34
+
35
+
36
+ @classmethod
37
+ def for_service(cls, payload: dict = {}) -> Token:
38
+ token = cls()
39
+
40
+ for key, value in payload.items():
41
+ token[key] = value
42
+
43
+ return token
44
+
45
+
46
+ class OrganizationAccessToken(Token):
47
+ token_type = oxi_settings.jwt_org_access_token_key
48
+ lifetime = timedelta(minutes=oxi_settings.jwt_org_access_token_lifetime)
49
+
50
+ @classmethod
51
+ def for_tenant(cls, tenant) -> Token:
52
+ token = cls()
53
+ token.payload['tenant_id'] = str(tenant.id)
54
+ token.payload['oxi_id'] = str(tenant.oxi_id)
55
+ token.payload['schema_name'] = str(tenant.schema_name)
56
+ token.payload['subscription_plan'] = str(tenant.subscription_plan)
57
+ token.payload['subscription_status'] = str(tenant.subscription_status)
58
+ token.payload['subscription_end_date'] = str(tenant.subscription_end_date)
59
+ token.payload['status'] = str(tenant.status)
60
+
61
+ return token
62
+
63
+ @property
64
+ def token_backend(self):
65
+ return token_backend
66
+
67
+ def get_token_backend(self):
68
+ # Backward compatibility.
69
+ return self.token_backend
oxutils/jwt/utils.py ADDED
@@ -0,0 +1,45 @@
1
+ from functools import wraps
2
+ import structlog
3
+ from django.contrib.auth import get_user_model
4
+ from django.http import HttpRequest
5
+
6
+ from ninja_jwt.exceptions import InvalidToken
7
+
8
+
9
+
10
+ logger = structlog.getLogger("django")
11
+ User = get_user_model()
12
+
13
+
14
+ def load_user(f):
15
+ """
16
+ Decorator that loads the complete user object from the database for stateless JWT authentication.
17
+ This is necessary because JWT tokens only contain the user ID, and the full user object
18
+ might be needed in the view methods.
19
+
20
+ Usage:
21
+
22
+ .. code-block:: python
23
+
24
+ @load_user
25
+ def my_view_method(self, *args, **kwargs):
26
+ # self.request.user will be the complete user object
27
+ pass
28
+ """
29
+ @wraps(f)
30
+ def wrapper(self, *args, **kwargs):
31
+ populate_user(self.context.request)
32
+ res = f(self, *args, **kwargs)
33
+ return res
34
+ return wrapper
35
+
36
+
37
+ def populate_user(request: HttpRequest):
38
+ if isinstance(request.user, User):
39
+ return
40
+
41
+ try:
42
+ request.user = User.objects.get(oxi_id=request.user.id)
43
+ except User.DoesNotExist as exc:
44
+ logger.exception('user_not_found', oxi_id=request.user.id, message=str(exc))
45
+ raise InvalidToken()
@@ -1,5 +1,5 @@
1
1
  import structlog
2
-
2
+ from oxutils.settings import oxi_settings
3
3
 
4
4
 
5
5
 
@@ -29,7 +29,7 @@ LOGGING = {
29
29
  },
30
30
  "json_file": {
31
31
  "class": "logging.handlers.WatchedFileHandler",
32
- "filename": "logs/json.log",
32
+ "filename": oxi_settings.log_file_path,
33
33
  "formatter": "json_formatter",
34
34
  },
35
35
  },
oxutils/oxiliere/apps.py CHANGED
@@ -3,4 +3,7 @@ from django.apps import AppConfig
3
3
 
4
4
  class OxiliereConfig(AppConfig):
5
5
  default_auto_field = 'django.db.models.BigAutoField'
6
- name = 'oxiliere'
6
+ name = 'oxutils.oxiliere'
7
+
8
+ def ready(self):
9
+ import oxutils.oxiliere.caches
@@ -1,9 +1,12 @@
1
+ from django.conf import settings
1
2
  from cacheops import cached_as, cached
2
- from oxutils.oxiliere.models import Tenant, TenantUser
3
- from django_tenants.utils import get_tenant_model
3
+ from oxutils.oxiliere.utils import get_tenant_model, get_tenant_user_model
4
+
5
+
4
6
 
5
7
 
6
8
  TenantModel = get_tenant_model()
9
+ TenantUserModel = get_tenant_user_model()
7
10
 
8
11
 
9
12
  @cached_as(TenantModel, timeout=60*15)
@@ -12,13 +15,13 @@ def get_tenant_by_oxi_id(oxi_id: str):
12
15
 
13
16
 
14
17
  @cached_as(TenantModel, timeout=60*15)
15
- def get_tenant_by_schema_name(schema_name: str) -> Tenant:
18
+ def get_tenant_by_schema_name(schema_name: str):
16
19
  return TenantModel.objects.get(schema_name=schema_name)
17
20
 
18
21
 
19
- @cached_as(TenantUser, timeout=60*15)
22
+ @cached_as(TenantUserModel, timeout=60*15)
20
23
  def get_tenant_user(oxi_org_id: str, oxi_user_id: str):
21
- return TenantUser.objects.get(
24
+ return TenantUserModel.objects.get(
22
25
  tenant__oxi_id=oxi_org_id,
23
26
  user__oxi_id=oxi_user_id
24
27
  )
@@ -9,7 +9,14 @@ from django_tenants.utils import (
9
9
  get_public_schema_name,
10
10
  get_public_schema_urlconf
11
11
  )
12
- from oxutils.constants import ORGANIZATION_HEADER_KEY
12
+ from oxutils.settings import oxi_settings
13
+ from oxutils.constants import (
14
+ ORGANIZATION_HEADER_KEY,
15
+ ORGANIZATION_TOKEN_COOKIE_KEY
16
+ )
17
+ from oxutils.jwt.models import TokenTenant
18
+ from oxutils.jwt.tokens import OrganizationAccessToken
19
+
13
20
 
14
21
  class TenantMainMiddleware(MiddlewareMixin):
15
22
  TENANT_NOT_FOUND_EXCEPTION = Http404
@@ -42,17 +49,48 @@ class TenantMainMiddleware(MiddlewareMixin):
42
49
  from django.http import HttpResponseBadRequest
43
50
  return HttpResponseBadRequest('Missing X-Organization-ID header')
44
51
 
45
- tenant_model = connection.tenant_model
46
- try:
47
- tenant = self.get_tenant(tenant_model, oxi_id)
48
- except tenant_model.DoesNotExist:
49
- default_tenant = self.no_tenant_found(request, oxi_id)
50
- return default_tenant
52
+ # Try to get tenant from cookie token first
53
+ tenant_token = request.COOKIES.get(ORGANIZATION_TOKEN_COOKIE_KEY)
54
+ tenant = None
55
+ request._should_set_tenant_cookie = False
56
+
57
+ if tenant_token:
58
+ tenant = TokenTenant.for_token(tenant_token)
59
+ # Verify the token's oxi_id matches the request
60
+ if tenant and tenant.oxi_id != oxi_id:
61
+ tenant = None
62
+
63
+ # If no valid token, fetch from database
64
+ if not tenant:
65
+ tenant_model = connection.tenant_model
66
+ try:
67
+ tenant = self.get_tenant(tenant_model, oxi_id)
68
+ # Mark that we need to set the cookie in the response
69
+ request._should_set_tenant_cookie = True
70
+ except tenant_model.DoesNotExist:
71
+ default_tenant = self.no_tenant_found(request, oxi_id)
72
+ return default_tenant
51
73
 
52
74
  request.tenant = tenant
53
75
  connection.set_tenant(request.tenant)
54
76
  self.setup_url_routing(request)
55
77
 
78
+ def process_response(self, request, response):
79
+ """Set the tenant token cookie if needed."""
80
+ if hasattr(request, '_should_set_tenant_cookie') and request._should_set_tenant_cookie:
81
+ if hasattr(request, 'tenant') and not isinstance(request.tenant, TokenTenant):
82
+ # Generate token from DB tenant
83
+ token = OrganizationAccessToken.for_tenant(request.tenant)
84
+ response.set_cookie(
85
+ key=ORGANIZATION_TOKEN_COOKIE_KEY,
86
+ value=str(token),
87
+ max_age=60 * oxi_settings.jwt_org_access_token_lifetime,
88
+ httponly=True,
89
+ secure=getattr(settings, 'SESSION_COOKIE_SECURE', False),
90
+ samesite='Lax',
91
+ )
92
+ return response
93
+
56
94
  def no_tenant_found(self, request, oxi_id):
57
95
  """ What should happen if no tenant is found.
58
96
  This makes it easier if you want to override the default behavior """
@@ -1,18 +1,25 @@
1
1
  from django.db import models
2
2
  from django.conf import settings
3
- from django_tenants.models import TenantMixin, DomainMixin
3
+ from django.utils.translation import gettext_lazy as _
4
+ from django_tenants.models import TenantMixin
4
5
  from oxutils.models import (
5
6
  TimestampMixin,
6
7
  BaseModelMixin,
8
+ UUIDPrimaryKeyMixin,
7
9
  )
8
10
  from oxutils.oxiliere.enums import TenantStatus
9
11
 
10
12
 
11
13
 
12
14
 
13
- class Tenant(TenantMixin, TimestampMixin):
15
+ tenant_model = getattr(settings, 'TENANT_MODEL', 'oxiliere.Tenant')
16
+ tenant_user_model = getattr(settings, 'TENANT_USER_MODEL', 'oxiliere.TenantUser')
17
+
18
+
19
+
20
+ class BaseTenant(TenantMixin, UUIDPrimaryKeyMixin, TimestampMixin):
14
21
  name = models.CharField(max_length=100)
15
- oxi_id = models.UUIDField(unique=True)
22
+ oxi_id = models.CharField(unique=True)
16
23
  subscription_plan = models.CharField(max_length=255, null=True, blank=True)
17
24
  subscription_status = models.CharField(max_length=255, null=True, blank=True)
18
25
  subscription_end_date = models.DateTimeField(null=True, blank=True)
@@ -25,15 +32,18 @@ class Tenant(TenantMixin, TimestampMixin):
25
32
  # default true, schema will be automatically created and synced when it is saved
26
33
  auto_create_schema = True
27
34
 
28
-
29
- class Domain(DomainMixin):
30
- pass
31
-
35
+ class Meta:
36
+ abstract = True
37
+ verbose_name = _('Tenant')
38
+ verbose_name_plural = _('Tenants')
39
+ indexes = [
40
+ models.Index(fields=['oxi_id'])
41
+ ]
32
42
 
33
43
 
34
- class TenantUser(BaseModelMixin):
44
+ class BaseTenantUser(BaseModelMixin):
35
45
  tenant = models.ForeignKey(
36
- settings.TENANT_MODEL, on_delete=models.CASCADE
46
+ tenant_model, on_delete=models.CASCADE
37
47
  )
38
48
  user = models.ForeignKey(
39
49
  settings.AUTH_USER_MODEL, on_delete=models.CASCADE
@@ -47,9 +57,25 @@ class TenantUser(BaseModelMixin):
47
57
  )
48
58
 
49
59
  class Meta:
60
+ abstract = True
61
+ verbose_name = 'Tenant User'
62
+ verbose_name_plural = 'Tenant Users'
50
63
  constraints = [
51
64
  models.UniqueConstraint(
52
65
  fields=['tenant', 'user'],
53
66
  name='unique_tenant_user'
54
67
  )
55
68
  ]
69
+ indexes = [
70
+ models.Index(fields=['tenant', 'user'])
71
+ ]
72
+
73
+
74
+ class Tenant(BaseTenant):
75
+ class Meta(BaseTenant.Meta):
76
+ abstract = not tenant_model == 'oxiliere.Tenant'
77
+
78
+
79
+ class TenantUser(BaseTenantUser):
80
+ class Meta(BaseTenantUser.Meta):
81
+ abstract = not tenant_user_model == 'oxiliere.TenantUser'
@@ -1,6 +1,7 @@
1
- from ninja.permissions import BasePermission
2
- from django.conf import settings
1
+ from ninja_extra.permissions import BasePermission
3
2
  from oxutils.oxiliere.models import TenantUser
3
+ from oxutils.constants import OXILIERE_SERVICE_TOKEN
4
+ from oxutils.jwt.tokens import OxilierServiceToken
4
5
 
5
6
 
6
7
  class TenantPermission(BasePermission):
@@ -8,7 +9,7 @@ class TenantPermission(BasePermission):
8
9
  Vérifie que l'utilisateur a accès au tenant actuel.
9
10
  L'utilisateur doit être authentifié et avoir un lien avec le tenant.
10
11
  """
11
- def has_permission(self, request, view):
12
+ def has_permission(self, request, **kwargs):
12
13
  if not request.user or not request.user.is_authenticated:
13
14
  return False
14
15
 
@@ -17,8 +18,8 @@ class TenantPermission(BasePermission):
17
18
 
18
19
  # Vérifier que l'utilisateur a accès à ce tenant
19
20
  return TenantUser.objects.filter(
20
- tenant=request.tenant,
21
- user=request.user
21
+ tenant__pk=request.tenant.pk,
22
+ user__pk=request.user.pk
22
23
  ).exists()
23
24
 
24
25
 
@@ -26,17 +27,16 @@ class TenantOwnerPermission(BasePermission):
26
27
  """
27
28
  Vérifie que l'utilisateur est propriétaire (owner) du tenant actuel.
28
29
  """
29
- def has_permission(self, request, view):
30
+ def has_permission(self, request, **kwargs):
30
31
  if not request.user or not request.user.is_authenticated:
31
32
  return False
32
33
 
33
34
  if not hasattr(request, 'tenant'):
34
35
  return False
35
36
 
36
- # Vérifier que l'utilisateur est owner du tenant
37
37
  return TenantUser.objects.filter(
38
- tenant=request.tenant,
39
- user=request.user,
38
+ tenant__pk=request.tenant.pk,
39
+ user__pk=request.user.pk,
40
40
  is_owner=True
41
41
  ).exists()
42
42
 
@@ -45,22 +45,17 @@ class TenantAdminPermission(BasePermission):
45
45
  """
46
46
  Vérifie que l'utilisateur est admin ou owner du tenant actuel.
47
47
  """
48
- def has_permission(self, request, view):
48
+ def has_permission(self, request, **kwargs):
49
49
  if not request.user or not request.user.is_authenticated:
50
50
  return False
51
51
 
52
52
  if not hasattr(request, 'tenant'):
53
53
  return False
54
54
 
55
- # Vérifier que l'utilisateur est admin ou owner du tenant
56
55
  return TenantUser.objects.filter(
57
- tenant=request.tenant,
58
- user=request.user,
56
+ tenant__pk=request.tenant.pk,
57
+ user__pk=request.user.pk,
59
58
  is_admin=True
60
- ).exists() or TenantUser.objects.filter(
61
- tenant=request.tenant,
62
- user=request.user,
63
- is_owner=True
64
59
  ).exists()
65
60
 
66
61
 
@@ -69,7 +64,7 @@ class TenantUserPermission(BasePermission):
69
64
  Vérifie que l'utilisateur est un membre du tenant actuel.
70
65
  Alias de TenantPermission pour plus de clarté sémantique.
71
66
  """
72
- def has_permission(self, request, view):
67
+ def has_permission(self, request, **kwargs):
73
68
  if not request.user or not request.user.is_authenticated:
74
69
  return False
75
70
 
@@ -77,8 +72,8 @@ class TenantUserPermission(BasePermission):
77
72
  return False
78
73
 
79
74
  return TenantUser.objects.filter(
80
- tenant=request.tenant,
81
- user=request.user
75
+ tenant__pk=request.tenant.pk,
76
+ user__pk=request.user.pk
82
77
  ).exists()
83
78
 
84
79
 
@@ -87,18 +82,15 @@ class OxiliereServicePermission(BasePermission):
87
82
  Vérifie que la requête provient d'un service interne Oxiliere.
88
83
  Utilise un token de service ou une clé API spéciale.
89
84
  """
90
- def has_permission(self, request, view):
91
- # Vérifier le header de service
92
- service_token = request.headers.get('X-Oxiliere-Service-Token')
85
+ def has_permission(self, request, **kwargs):
86
+ custom = 'HTTP_' + OXILIERE_SERVICE_TOKEN.upper().replace('-', '_')
87
+ service_token = request.headers.get(OXILIERE_SERVICE_TOKEN) or request.META.get(custom)
93
88
 
94
89
  if not service_token:
95
90
  return False
96
91
 
97
- # Comparer avec le token configuré dans settings
98
- expected_token = getattr(settings, 'OXILIERE_SERVICE_TOKEN', None)
99
-
100
- if not expected_token:
92
+ try:
93
+ OxilierServiceToken(token=service_token)
94
+ return True
95
+ except Exception:
101
96
  return False
102
-
103
- return service_token == expected_token
104
-
@@ -1,4 +1,5 @@
1
1
  from typing import Optional
2
+ from uuid import UUID
2
3
  from ninja import Schema
3
4
  from django.db import transaction
4
5
  from django.contrib.auth import get_user_model
@@ -20,7 +21,7 @@ class TenantSchema(Schema):
20
21
 
21
22
 
22
23
  class TenantOwnerSchema(Schema):
23
- oxi_id: str
24
+ oxi_id: UUID
24
25
  email: str
25
26
 
26
27
 
@@ -38,7 +39,7 @@ class CreateTenantSchema(Schema):
38
39
  logger.info("tenant_exists", oxi_id=self.tenant.oxi_id)
39
40
  raise ValueError("Tenant with oxi_id {} already exists".format(self.tenant.oxi_id))
40
41
 
41
- user = UserModel.objects.get_or_create(
42
+ user, _ = UserModel.objects.get_or_create(
42
43
  oxi_id=self.owner.oxi_id,
43
44
  defaults={
44
45
  'email': self.owner.email,
oxutils/oxiliere/utils.py CHANGED
@@ -1,4 +1,25 @@
1
- # utils.py
1
+ from typing import Any
2
+ from django.apps import apps
3
+ from django.conf import settings
4
+
5
+
6
+
7
+
8
+ def get_model(setting: str) -> Any:
9
+ try:
10
+ value = getattr(settings, setting)
11
+ except AttributeError:
12
+ raise ValueError(f"Model `{setting}` is not a valid Tenant model.")
13
+
14
+ return apps.get_model(value)
15
+
16
+
17
+ def get_tenant_model() -> Any:
18
+ return get_model('TENANT_MODEL')
19
+
20
+ def get_tenant_user_model() -> Any:
21
+ return get_model('TENANT_USER_MODEL')
22
+
2
23
 
3
24
  def oxid_to_schema_name(oxid: str) -> str:
4
25
  """
oxutils/settings.py CHANGED
@@ -28,14 +28,21 @@ class OxUtilsSettings(BaseSettings):
28
28
  jwt_signing_key: Optional[str] = None
29
29
  jwt_verifying_key: Optional[str] = None
30
30
  jwt_jwks_url: Optional[str] = None
31
- jwt_access_token_key: str = Field('access_token')
32
- jwt_org_access_token_key: str = Field('org_access_token')
31
+ jwt_access_token_key: str = Field('access')
32
+ jwt_org_access_token_key: str = Field('org_access')
33
+ jwt_service_token_key: str = Field('service')
34
+ jwt_algorithm: Optional[str] = Field('RS256')
35
+ jwt_access_token_lifetime: int = Field(15) # minutes
36
+ jwt_service_token_lifetime: int = Field(3) # minutes
37
+ jwt_org_access_token_lifetime: int = Field(60) # minutes
33
38
 
34
39
 
35
40
  # AuditLog
36
41
  log_access: bool = Field(False)
37
42
  retention_delay: int = Field(7) # one week
38
43
 
44
+ # logger
45
+ log_file_path: Optional[str] = Field('logs/oxiliere.log')
39
46
 
40
47
  # Static S3
41
48
  use_static_s3: bool = Field(False)
oxutils/users/apps.py CHANGED
@@ -3,4 +3,4 @@ from django.apps import AppConfig
3
3
 
4
4
  class UsersConfig(AppConfig):
5
5
  default_auto_field = 'django.db.models.BigAutoField'
6
- name = 'users'
6
+ name = 'oxutils.users'
@@ -0,0 +1,47 @@
1
+ # Generated by Django 5.2.9 on 2025-12-23 10:52
2
+
3
+ import django.utils.timezone
4
+ import uuid
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = [
13
+ ('auth', '0012_alter_user_first_name_max_length'),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.CreateModel(
18
+ name='User',
19
+ fields=[
20
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
21
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
22
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
23
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
24
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
25
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
26
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unique identifier for this record', primary_key=True, serialize=False)),
27
+ ('created_at', models.DateTimeField(auto_now_add=True, help_text='Date and time when this record was created')),
28
+ ('updated_at', models.DateTimeField(auto_now=True, help_text='Date and time when this record was last updated')),
29
+ ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)),
30
+ ('deleted_by_cascade', models.BooleanField(default=False, editable=False)),
31
+ ('oxi_id', models.UUIDField(unique=True)),
32
+ ('email', models.EmailField(max_length=254, unique=True)),
33
+ ('is_active', models.BooleanField(default=True)),
34
+ ('subscription_plan', models.CharField(blank=True, max_length=255, null=True)),
35
+ ('subscription_status', models.CharField(blank=True, max_length=255, null=True)),
36
+ ('subscription_end_date', models.DateTimeField(blank=True, null=True)),
37
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
38
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
39
+ ],
40
+ options={
41
+ 'verbose_name': 'utilisateur',
42
+ 'verbose_name_plural': 'utilisateurs',
43
+ 'ordering': ['-created_at'],
44
+ 'indexes': [models.Index(fields=['oxi_id'], name='users_user_oxi_id_389c72_idx'), models.Index(fields=['email'], name='users_user_email_6f2530_idx')],
45
+ },
46
+ ),
47
+ ]
oxutils/utils.py ADDED
@@ -0,0 +1,25 @@
1
+ from django.http import HttpRequest
2
+
3
+
4
+ def get_client_ip(request: HttpRequest) -> str:
5
+ """
6
+ Extract client IP address from request metadata.
7
+
8
+ Priority:
9
+
10
+ 1. X-Forwarded-For header (first entry if multiple)
11
+ 2. REMOTE_ADDR meta value
12
+
13
+ Args:
14
+ request (HttpRequest): Django request object
15
+
16
+ Returns:
17
+ str: Client IP address or None if not found
18
+ """
19
+ x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
20
+ if x_forwarded_for:
21
+ ip = x_forwarded_for.split(',')[0]
22
+ else:
23
+ ip = request.META.get('REMOTE_ADDR')
24
+ return ip
25
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oxutils
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: Production-ready utilities for Django applications in the Oxiliere ecosystem
5
5
  Keywords: django,utilities,jwt,s3,audit,logging,celery,structlog
6
6
  Author: Edimedia Mutoke
@@ -30,9 +30,19 @@ Requires-Dist: jwcrypto>=1.5.6
30
30
  Requires-Dist: pydantic-settings>=2.12.0
31
31
  Requires-Dist: pyjwt>=2.10.1
32
32
  Requires-Dist: requests>=2.32.5
33
+ Requires-Dist: bcc-rates>=1.1.0 ; extra == 'all'
34
+ Requires-Dist: django-cacheops>=7.2 ; extra == 'all'
35
+ Requires-Dist: django-tenants>=3.9.0 ; extra == 'all'
36
+ Requires-Dist: django-safedelete>=1.4.1 ; extra == 'all'
37
+ Requires-Dist: django-auditlog>=3.4.1 ; extra == 'all'
38
+ Requires-Dist: django-ninja-jwt>=5.4.2 ; extra == 'all'
39
+ Requires-Dist: weasyprint>=67.0 ; extra == 'all'
33
40
  Requires-Dist: bcc-rates>=1.1.0 ; extra == 'currency'
41
+ Requires-Dist: django-ninja-jwt>=5.4.2 ; extra == 'jwt'
34
42
  Requires-Dist: django-cacheops>=7.2 ; extra == 'oxiliere'
35
43
  Requires-Dist: django-tenants>=3.9.0 ; extra == 'oxiliere'
44
+ Requires-Dist: django-safedelete>=1.4.1 ; extra == 'oxiliere'
45
+ Requires-Dist: django-auditlog>=3.4.1 ; extra == 'oxiliere'
36
46
  Requires-Dist: weasyprint>=67.0 ; extra == 'pdf'
37
47
  Requires-Python: >=3.12
38
48
  Project-URL: Changelog, https://github.com/oxiliere/oxutils/blob/main/CHANGELOG.md
@@ -40,7 +50,9 @@ Project-URL: Documentation, https://github.com/oxiliere/oxutils/tree/main/docs
40
50
  Project-URL: Homepage, https://github.com/oxiliere/oxutils
41
51
  Project-URL: Issues, https://github.com/oxiliere/oxutils/issues
42
52
  Project-URL: Repository, https://github.com/oxiliere/oxutils
53
+ Provides-Extra: all
43
54
  Provides-Extra: currency
55
+ Provides-Extra: jwt
44
56
  Provides-Extra: oxiliere
45
57
  Provides-Extra: pdf
46
58
  Description-Content-Type: text/markdown
@@ -1,4 +1,4 @@
1
- oxutils/__init__.py,sha256=dR4pOinh8r-27oEc1KHgk1_rJ0MY6ieLFiQ0Y9gvIbQ,536
1
+ oxutils/__init__.py,sha256=cSYDL8bZ0uhcOS2f6NLOnk7-6U19dsz0lLXtSpIG0_M,536
2
2
  oxutils/apps.py,sha256=8pO8eXUZeKYn8fPo0rkoytmHACwDNuTNhdRcpkPTxGM,347
3
3
  oxutils/audit/__init__.py,sha256=uonc00G73Xm7RwRHVWD-wBn8lJYNCq3iBgnRGMWAEWs,583
4
4
  oxutils/audit/apps.py,sha256=xvnmB5Z6nLV7ejzhSeQbesTkwRoFygoPFob8H5QTHgU,304
@@ -13,7 +13,7 @@ oxutils/celery/__init__.py,sha256=29jo4DfdvOoThX-jfL0ZiDjsy3-Z_fNhwHVJaLO5rsk,29
13
13
  oxutils/celery/base.py,sha256=qLlBU2XvT2zj8qszy8togqH7hM_wUYyWWA3JAQPPJx0,3378
14
14
  oxutils/celery/settings.py,sha256=njhHBErpcFczV2e23NCPX_Jxs015jr4dIig4Is_wbgE,33
15
15
  oxutils/conf.py,sha256=TR0RIVaLMHvG0gm3NgbKsoU25eJFBjItAhknFJdiOiQ,231
16
- oxutils/constants.py,sha256=CW-ho9Vasys-L2ZNJHFMovG5NzHNU6GPldE9vqyVcLM,89
16
+ oxutils/constants.py,sha256=-sBwwte7rxVQ6whvYUaz-NgGUD56YVP3u-TnInugCvM,186
17
17
  oxutils/context/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
18
  oxutils/context/site_name_processor.py,sha256=1gc0Td_3HVlUn9ThhQBCQ8kfnRnI88bEflK9vEzTvEc,225
19
19
  oxutils/currency/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -34,12 +34,13 @@ oxutils/exceptions.py,sha256=CCjENOD0of6_noif2ajrpfbBLoG16DWa46iB9_uEe3M,3592
34
34
  oxutils/functions.py,sha256=4stHj94VebWX0s1XeWshubMD2v8w8QztTWppbkTE_Gg,3246
35
35
  oxutils/jwt/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
36
  oxutils/jwt/auth.py,sha256=rO-xWNfug9Ok6zA7EIPvVkpD8TBUdq05CdrnMrL-t9Q,1597
37
- oxutils/jwt/client.py,sha256=bskLpmSBrehi_snbo3Qbq1m99Kbfg2GP7jqfcXKHvys,3341
38
- oxutils/jwt/constants.py,sha256=MUahZjm8plTYpHjLOMQCuH0H18lkIwS45EtRm617wq8,26
37
+ oxutils/jwt/models.py,sha256=xAoIeGw6tqo91g0bupgig6F6OE46CiglREyw7jM4wc0,1854
38
+ oxutils/jwt/tokens.py,sha256=kWgtPl4XxV0xHkjbhd5QteQy8Wv5MsvyLcLVyO-gzuo,1822
39
+ oxutils/jwt/utils.py,sha256=Wuy-PnCcUw6MpY6z1Isy2vOx-_u1o6LjUfRJgf_cqbY,1202
39
40
  oxutils/locale/fr/LC_MESSAGES/django.po,sha256=APXt_8R99seCWjJyS5ELOawvRLvUqqBT32O252BaG5s,7971
40
41
  oxutils/logger/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
41
42
  oxutils/logger/receivers.py,sha256=EBpkwMCHYacOJvuOPzUtM_8ttWWetz17kIwoudGiV34,488
42
- oxutils/logger/settings.py,sha256=aiKiJqNNkw1g5vQgjk3Zfh6UgY7jx-lbmcFgATiXrGI,1805
43
+ oxutils/logger/settings.py,sha256=VILlpWtq9JYYSUtU-sulP8sCqTXWzn7Aae8GRAwSDms,1857
43
44
  oxutils/mixins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
44
45
  oxutils/mixins/base.py,sha256=0cGY4mGKhL-hJTEBsbETYiaKMVuUgio5DISCv5iYtGI,589
45
46
  oxutils/mixins/schemas.py,sha256=DGW0GQBJc9K7hwOJLX-fEryi7KCoY1QaCLny8fjtQMI,319
@@ -50,22 +51,22 @@ oxutils/models/billing.py,sha256=aCDZcMx4CUyAwh3wgJGypAJl_fSEuWrL27-cSYv3gCs,332
50
51
  oxutils/models/invoice.py,sha256=nqphkhlBhssODm2H4dBYyb1cOmHS29fToER40UN0cXo,13216
51
52
  oxutils/oxiliere/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
53
  oxutils/oxiliere/admin.py,sha256=suMo4x8I3JBxAFBVIdE-5qnqZ6JAZV0FESABHOSc-vg,63
53
- oxutils/oxiliere/apps.py,sha256=DAb8O1Vt4XPTIlFg5NB6SISTYRHyW3TY55-l7TC08Lo,148
54
+ oxutils/oxiliere/apps.py,sha256=BLS5slH_vuIZP89jh2EAoRteBRsh4IrpzgO80p8CLUk,217
54
55
  oxutils/oxiliere/cacheops.py,sha256=VGG5qG5IsxvWJTu1aTlmsaDXV2aiuIMVdVcvHGHeY3g,163
55
- oxutils/oxiliere/caches.py,sha256=PljqpDxkaE5F8Gsmvdqoq8W0MDh6aDtgXxCrN2T8b-4,976
56
+ oxutils/oxiliere/caches.py,sha256=eKZCVekWSzwdEX2LQat3B4JqDIcrumZI9x_wXW1xXCY,1023
56
57
  oxutils/oxiliere/controllers.py,sha256=hL_snutY1EuSO0n06NDbjkz-3A3hkqa3sYGie3Grbmg,847
57
58
  oxutils/oxiliere/enums.py,sha256=etpHgzsHTQKMrPIxVH-d592_DYSRJ2_o0c8bOdIza-8,201
58
59
  oxutils/oxiliere/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
60
  oxutils/oxiliere/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
60
61
  oxutils/oxiliere/management/commands/init_oxiliere_system.py,sha256=ogNQGARqUUfTv4JgZsRuEh4FInfMUO4nFHHCbgeLLbk,3659
61
- oxutils/oxiliere/middleware.py,sha256=mGOUs0bjtp_TKkZRgcC6kE7euZrZ6mIG1PgKHBhTDCc,4013
62
+ oxutils/oxiliere/middleware.py,sha256=NgC2FqYQVJTB2vujY_9CJ8eSoQ2CuFcHDVZWKuQJk1s,5672
62
63
  oxutils/oxiliere/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
63
- oxutils/oxiliere/models.py,sha256=IqASvymJ6EcLc9bhxnas7-XXZStQYz7_UqroFkqS8xE,1543
64
- oxutils/oxiliere/permissions.py,sha256=BVSbXqMa9Ohb1joBA_uXRCkYsYc3_CabhAWL9-VS4IU,3227
65
- oxutils/oxiliere/schemas.py,sha256=kjX6N1INx9wikatniEM5s-ECuuVtDCcu6VEBbbEIdZE,1897
64
+ oxutils/oxiliere/models.py,sha256=EQHoQIzRVBa2VH7J44v-jc3dUZGNTjw_S61RuKcT6lw,2382
65
+ oxutils/oxiliere/permissions.py,sha256=B171KebFVxZdAAdyNB36vsyjHg_cfdJkIb4LsgMmIVE,3059
66
+ oxutils/oxiliere/schemas.py,sha256=WnWnGTyBQRP_cWyPNuvJwEkdJUPFRwG-dqzVJOfAVKU,1924
66
67
  oxutils/oxiliere/settings.py,sha256=ZuKppEyrucWxvvYC2-wLap4RzKfaEfaRdjJnsNZzpuY,440
67
68
  oxutils/oxiliere/tests.py,sha256=mrbGGRNg5jwbTJtWWa7zSKdDyeB4vmgZCRc2nk6VY-g,60
68
- oxutils/oxiliere/utils.py,sha256=iZc3BjaqVSrZkELVpYsYiwMHC5MfDeDQPKn8iTsB3Bk,2488
69
+ oxutils/oxiliere/utils.py,sha256=iSxvtMWHKPVDHyQuVflw6vObP6N3IpVOFxpV_CKpgbI,2941
69
70
  oxutils/pdf/__init__.py,sha256=Uu_yOEd-FcNHIB7CV6y76c53wjL5Hce2GMjho8gnkbM,236
70
71
  oxutils/pdf/printer.py,sha256=VVvpGU5GdSNTOP06wLqgm36ICDfRTRaRjmmiS-vJ0wM,2449
71
72
  oxutils/pdf/utils.py,sha256=cn1Yc7KnjuATSQKM3hrYptCo0IsQurBdrNHh7Nu08b8,3786
@@ -74,15 +75,17 @@ oxutils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
74
75
  oxutils/s3/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
75
76
  oxutils/s3/settings.py,sha256=NIlVhaOzWdsepOgCpxdTTJRHfM0tM5EcAoy4kaFC1R8,1190
76
77
  oxutils/s3/storages.py,sha256=gjQg05edVn6NuyfJZ-NwUB2lRWwg8GqgzHB8I1D5vbI,5402
77
- oxutils/settings.py,sha256=mp_ZSf8bcCUe2Zc9YdOe_EtltBgIY2n_8lUERQtsIFk,9742
78
+ oxutils/settings.py,sha256=a7eLYIrwIvob0YwQc--XjQLGhY5ax0Iu036s2DV51qQ,10079
78
79
  oxutils/types.py,sha256=DIz8YK8xMpLc7FYbf88yEElyLsYN_-rbvaZXvENQkOQ,234
79
80
  oxutils/users/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
80
81
  oxutils/users/admin.py,sha256=suMo4x8I3JBxAFBVIdE-5qnqZ6JAZV0FESABHOSc-vg,63
81
- oxutils/users/apps.py,sha256=u3PAY2AZGQXSWH2DSkQ1lOTIVm33qUbzeKls0J8H8No,142
82
+ oxutils/users/apps.py,sha256=zfWHq8f0DIh8skbnqskDSoHG9nrvVrCegSz22Mw4BGI,150
83
+ oxutils/users/migrations/0001_initial.py,sha256=7l5xgJnms2D8Nnazh38iBQ7I1W9NgNTF228-og_tOVw,3136
82
84
  oxutils/users/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
83
85
  oxutils/users/models.py,sha256=YXAHcXuhmHZfcRDOqA5lBiSRcJ24snBs9X6tcJPCcFs,2960
84
86
  oxutils/users/tests.py,sha256=mrbGGRNg5jwbTJtWWa7zSKdDyeB4vmgZCRc2nk6VY-g,60
85
87
  oxutils/users/utils.py,sha256=jY-zL8vLT5U3E2FV3DqCvrPORjKLutbkPZTQ-z96dCw,376
86
- oxutils-0.1.6.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
87
- oxutils-0.1.6.dist-info/METADATA,sha256=9frFJnEQATQH0nvB5a0WbDWVc7M-mI--wy9KkSOeKkA,8168
88
- oxutils-0.1.6.dist-info/RECORD,,
88
+ oxutils/utils.py,sha256=6yGX2d1ajU5RqgfqiaS4McYm7ip2KEgADABo3M-yA3U,595
89
+ oxutils-0.1.8.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
90
+ oxutils-0.1.8.dist-info/METADATA,sha256=_CW_yBDOyOTv04ABahWzZbJ0XJHlnNqutefdq089hVA,8759
91
+ oxutils-0.1.8.dist-info/RECORD,,
oxutils/jwt/client.py DELETED
@@ -1,123 +0,0 @@
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 DELETED
@@ -1 +0,0 @@
1
- JWT_ALGORITHM = ["RS256"]