oxutils 0.1.2__py3-none-any.whl → 0.1.5__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.
Files changed (55) hide show
  1. oxutils/__init__.py +1 -1
  2. oxutils/audit/settings.py +1 -16
  3. oxutils/audit/utils.py +22 -0
  4. oxutils/conf.py +1 -3
  5. oxutils/constants.py +2 -0
  6. oxutils/context/__init__.py +0 -0
  7. oxutils/context/site_name_processor.py +11 -0
  8. oxutils/currency/__init__.py +0 -0
  9. oxutils/currency/admin.py +57 -0
  10. oxutils/currency/apps.py +7 -0
  11. oxutils/currency/controllers.py +79 -0
  12. oxutils/currency/enums.py +7 -0
  13. oxutils/currency/migrations/0001_initial.py +41 -0
  14. oxutils/currency/migrations/__init__.py +0 -0
  15. oxutils/currency/models.py +100 -0
  16. oxutils/currency/schemas.py +38 -0
  17. oxutils/currency/tests.py +3 -0
  18. oxutils/currency/utils.py +69 -0
  19. oxutils/functions.py +5 -2
  20. oxutils/logger/receivers.py +0 -2
  21. oxutils/oxiliere/__init__.py +0 -0
  22. oxutils/oxiliere/admin.py +3 -0
  23. oxutils/oxiliere/apps.py +6 -0
  24. oxutils/oxiliere/cacheops.py +7 -0
  25. oxutils/oxiliere/caches.py +33 -0
  26. oxutils/oxiliere/controllers.py +36 -0
  27. oxutils/oxiliere/enums.py +10 -0
  28. oxutils/oxiliere/management/__init__.py +0 -0
  29. oxutils/oxiliere/management/commands/__init__.py +0 -0
  30. oxutils/oxiliere/management/commands/init_oxiliere_system.py +86 -0
  31. oxutils/oxiliere/middleware.py +97 -0
  32. oxutils/oxiliere/migrations/__init__.py +0 -0
  33. oxutils/oxiliere/models.py +55 -0
  34. oxutils/oxiliere/permissions.py +104 -0
  35. oxutils/oxiliere/schemas.py +65 -0
  36. oxutils/oxiliere/settings.py +17 -0
  37. oxutils/oxiliere/tests.py +3 -0
  38. oxutils/oxiliere/utils.py +76 -0
  39. oxutils/pdf/__init__.py +10 -0
  40. oxutils/pdf/printer.py +81 -0
  41. oxutils/pdf/utils.py +94 -0
  42. oxutils/pdf/views.py +208 -0
  43. oxutils/settings.py +2 -0
  44. oxutils/users/__init__.py +0 -0
  45. oxutils/users/admin.py +3 -0
  46. oxutils/users/apps.py +6 -0
  47. oxutils/users/migrations/__init__.py +0 -0
  48. oxutils/users/models.py +88 -0
  49. oxutils/users/tests.py +3 -0
  50. oxutils/users/utils.py +15 -0
  51. {oxutils-0.1.2.dist-info → oxutils-0.1.5.dist-info}/METADATA +92 -11
  52. oxutils-0.1.5.dist-info/RECORD +88 -0
  53. {oxutils-0.1.2.dist-info → oxutils-0.1.5.dist-info}/WHEEL +1 -1
  54. oxutils/locale/fr/LC_MESSAGES/django.mo +0 -0
  55. oxutils-0.1.2.dist-info/RECORD +0 -45
oxutils/pdf/utils.py ADDED
@@ -0,0 +1,94 @@
1
+ import logging
2
+ import mimetypes
3
+ import os
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+ from urllib.parse import urlparse
7
+
8
+ import weasyprint
9
+
10
+ from django.conf import settings
11
+ from django.contrib.staticfiles.finders import find
12
+ from django.contrib.staticfiles.storage import staticfiles_storage
13
+ from django.core.files.storage import default_storage
14
+ from django.urls import get_script_prefix
15
+
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ @lru_cache(maxsize=None)
21
+ def get_reversed_hashed_files():
22
+ return {v: k for k, v in staticfiles_storage.hashed_files.items()}
23
+
24
+
25
+ def django_url_fetcher(url, *args, **kwargs):
26
+ # attempt to load file:// paths to Django MEDIA or STATIC files directly from disk
27
+ if url.startswith('file:'):
28
+ log.debug('Attempt to fetch from %s', url)
29
+ mime_type, encoding = mimetypes.guess_type(url)
30
+ url_path = urlparse(url).path
31
+ data = {
32
+ 'mime_type': mime_type,
33
+ 'encoding': encoding,
34
+ 'filename': Path(url_path).name,
35
+ }
36
+
37
+ default_media_url = settings.MEDIA_URL in ('', get_script_prefix())
38
+ if not default_media_url and url_path.startswith(settings.MEDIA_URL):
39
+ log.debug('URL contains MEDIA_URL (%s)', settings.MEDIA_URL)
40
+ cleaned_media_root = str(settings.MEDIA_ROOT)
41
+ if not cleaned_media_root.endswith('/'):
42
+ cleaned_media_root += '/'
43
+ absolute_path = url_path.replace(settings.MEDIA_URL, cleaned_media_root, 1)
44
+ log.debug('Cleaned path: %s', absolute_path)
45
+ data['file_obj'] = default_storage.open(absolute_path, 'rb')
46
+ data['redirected_url'] = 'file://' + absolute_path
47
+ return data
48
+
49
+ # path looks like a static file based on configured STATIC_URL
50
+ elif settings.STATIC_URL and url_path.startswith(settings.STATIC_URL):
51
+ log.debug('URL contains STATIC_URL (%s)', settings.STATIC_URL)
52
+ # strip the STATIC_URL prefix to get the relative filesystem path
53
+ relative_path = url_path.replace(settings.STATIC_URL, '', 1)
54
+ # detect hashed files storage and get path with un-hashed filename
55
+ if not settings.DEBUG and hasattr(staticfiles_storage, 'hashed_files'):
56
+ log.debug('Hashed static files storage detected')
57
+ relative_path = get_reversed_hashed_files()[relative_path]
58
+ data['filename'] = Path(relative_path).name
59
+ log.debug('Cleaned path: %s', relative_path)
60
+ # find the absolute path using the static file finders
61
+ absolute_path = find(relative_path)
62
+ log.debug('Static file finder returned: %s', absolute_path)
63
+ if absolute_path:
64
+ log.debug('Loading static file: %s', absolute_path)
65
+ data['file_obj'] = open(absolute_path, 'rb') # noqa: PTH123
66
+ data['redirected_url'] = 'file://' + absolute_path
67
+ return data
68
+
69
+ # Fall back to weasyprint default fetcher for http/s: and file: paths
70
+ # that did not match MEDIA_URL or STATIC_URL.
71
+ log.debug('Forwarding to weasyprint.default_url_fetcher: %s', url)
72
+ return weasyprint.default_url_fetcher(url, *args, **kwargs)
73
+
74
+
75
+ def get_stylesheet_path(relative_path):
76
+ if settings.DEBUG:
77
+ path = find(relative_path)
78
+ if path:
79
+ return path
80
+ print('Le DEBUG est False')
81
+ static_root_path = os.path.join(settings.STATIC_ROOT, relative_path)
82
+ if os.path.exists(static_root_path):
83
+ return static_root_path
84
+
85
+ return None
86
+
87
+
88
+ def get_stylesheets(*relative_paths):
89
+ stylesheets = []
90
+ for path in relative_paths:
91
+ resolved = get_stylesheet_path(path)
92
+ if resolved:
93
+ stylesheets.append(resolved)
94
+ return stylesheets
oxutils/pdf/views.py ADDED
@@ -0,0 +1,208 @@
1
+ import weasyprint
2
+
3
+ from django.conf import settings
4
+ from django.template.response import TemplateResponse
5
+ from django.views.generic.base import ContextMixin, TemplateResponseMixin, View
6
+
7
+ from oxutils.pdf.utils import django_url_fetcher
8
+
9
+
10
+ class WeasyTemplateResponse(TemplateResponse):
11
+ def __init__(
12
+ self,
13
+ request,
14
+ template,
15
+ context=None,
16
+ content_type=None,
17
+ status=None,
18
+ charset=None,
19
+ using=None,
20
+ headers=None,
21
+ filename=None,
22
+ attachment=True,
23
+ stylesheets=None,
24
+ options=None,
25
+ ):
26
+ """
27
+ An HTTP response class with template and context rendered to a PDF document.
28
+
29
+ Django TemplateResponse arguments:
30
+
31
+ :param request: the request object
32
+ :param template: template to use to render the response
33
+ :param context: context to use to render the response
34
+ :param content_type: content type of the response (default: 'application/pdf')
35
+ :param status: status code of the response (default: 200)
36
+ :param charset: character set of the response (default: settings.DEFAULT_CHARSET)
37
+ :param using: template engine to use (default: 'django')
38
+ :param headers: dictionary of headers to use in the response
39
+
40
+ WeasyPrint specific arguments:
41
+
42
+ :param filename: set `Content-Disposition` to use this filename
43
+ :param attachment: set `Content-Disposition` 'attachment';
44
+ A `filename` must be given to enable this even if set to `True`.
45
+ (default: `True`)
46
+ :param stylesheets: list of additional stylesheets
47
+ :param options: dictionary of options passed to WeasyPrint
48
+ """
49
+ self._stylesheets = stylesheets or []
50
+ self._options = options.copy() if options else {}
51
+
52
+ kwargs = dict(
53
+ context=context,
54
+ content_type=content_type or WeasyTemplateResponseMixin.content_type,
55
+ status=status,
56
+ charset=charset,
57
+ using=using,
58
+ headers=headers,
59
+ )
60
+ super().__init__(request, template, **kwargs)
61
+
62
+ if filename:
63
+ display = 'attachment' if attachment else 'inline'
64
+ self['Content-Disposition'] = f'{display};filename="{filename}"'
65
+
66
+ def get_base_url(self):
67
+ """
68
+ Determine base URL to fetch CSS or other files referenced with relative
69
+ paths in the HTML files using the `WEASYPRINT_BASEURL` setting or
70
+ fall back to using the root path of the URL used in the request.
71
+ """
72
+ return getattr(
73
+ settings, 'WEASYPRINT_BASEURL',
74
+ self._request.build_absolute_uri('/')
75
+ )
76
+
77
+ def get_url_fetcher(self):
78
+ """
79
+ Determine the URL fetcher to fetch CSS, images, fonts, etc. from.
80
+ """
81
+ return django_url_fetcher
82
+
83
+ def get_font_config(self):
84
+ """
85
+ A FreeType font configuration to handle @font-config rules.
86
+ """
87
+ return weasyprint.text.fonts.FontConfiguration()
88
+
89
+ def get_css(self, base_url, url_fetcher, font_config):
90
+ """
91
+ Load additional stylesheets.
92
+ """
93
+ return [
94
+ weasyprint.CSS(
95
+ value,
96
+ base_url=base_url,
97
+ url_fetcher=url_fetcher,
98
+ font_config=font_config,
99
+ )
100
+ for value
101
+ in self._stylesheets
102
+ ]
103
+
104
+ def get_document(self):
105
+ """
106
+ Returns a :class:`~document.Document` object which provides
107
+ access to individual pages and various meta-data.
108
+
109
+ See :meth:`weasyprint.HTML.render` and
110
+ :meth:`weasyprint.document.Document.write_pdf` on how to generate a
111
+ PDF file.
112
+ """
113
+ base_url = self.get_base_url()
114
+ url_fetcher = self.get_url_fetcher()
115
+ font_config = self.get_font_config()
116
+
117
+ html = weasyprint.HTML(
118
+ string=super().rendered_content,
119
+ base_url=base_url,
120
+ url_fetcher=url_fetcher,
121
+ )
122
+
123
+ self._options.setdefault(
124
+ 'stylesheets',
125
+ self.get_css(base_url, url_fetcher, font_config),
126
+ )
127
+
128
+ return html.render(
129
+ font_config=font_config,
130
+ **self._options,
131
+ )
132
+
133
+ @property
134
+ def rendered_content(self):
135
+ """
136
+ Returns rendered PDF pages.
137
+ """
138
+ document = self.get_document()
139
+ return document.write_pdf(**self._options)
140
+
141
+
142
+ class WeasyTemplateResponseMixin(TemplateResponseMixin):
143
+ """
144
+ Mixin for a CBV creating a ``WeasyTemplateResponse`` using the configured template.
145
+ """
146
+ response_class = WeasyTemplateResponse
147
+ content_type = 'application/pdf'
148
+ pdf_filename = None
149
+ pdf_attachment = True
150
+ pdf_stylesheets = []
151
+ pdf_options = {}
152
+
153
+ def get_pdf_filename(self):
154
+ """
155
+ Returns :attr:`pdf_filename` value by default.
156
+
157
+ If left blank the browser will display the PDF inline.
158
+ Otherwise it will pop up the "Save as.." dialog.
159
+
160
+ :rtype: :func:`str`
161
+ """
162
+ return self.pdf_filename
163
+
164
+ def get_pdf_stylesheets(self):
165
+ """
166
+ Returns a list of stylesheet filenames to use when rendering.
167
+
168
+ :rtype: :func:`list`
169
+ """
170
+ return self.pdf_stylesheets
171
+
172
+ def get_pdf_options(self):
173
+ """
174
+ Returns dictionary of WeasyPrint options.
175
+ """
176
+ return self.pdf_options
177
+
178
+ def render_to_response(self, context, **response_kwargs):
179
+ """
180
+ Renders PDF document and prepares response by calling on
181
+ :attr:`response_class` (default: :class:`WeasyTemplateResponse`).
182
+
183
+ :returns: Django HTTP response
184
+ :rtype: :class:`django.http.HttpResponse`
185
+ """
186
+ response_kwargs.update({
187
+ 'attachment': self.pdf_attachment,
188
+ 'filename': self.get_pdf_filename(),
189
+ 'stylesheets': self.get_pdf_stylesheets(),
190
+ 'options': self.get_pdf_options(),
191
+ })
192
+ return super().render_to_response(
193
+ context, **response_kwargs
194
+ )
195
+
196
+
197
+ class WeasyTemplateView(WeasyTemplateResponseMixin, ContextMixin, View):
198
+ """
199
+ Concrete view for serving PDF files.
200
+
201
+ .. code-block:: python
202
+
203
+ class HelloPDFView(WeasyTemplateView):
204
+ template_name = "hello.html"
205
+ """
206
+ def get(self, request, *args, **kwargs):
207
+ context = self.get_context_data(**kwargs)
208
+ return self.render_to_response(context)
oxutils/settings.py CHANGED
@@ -21,6 +21,8 @@ class OxUtilsSettings(BaseSettings):
21
21
 
22
22
  # Service
23
23
  service_name: Optional[str] = 'Oxutils'
24
+ site_name: Optional[str] = 'Oxiliere'
25
+ site_domain: Optional[str] = 'oxiliere.com'
24
26
 
25
27
  # Auth JWT Settings (JWT_SIGNING_KEY)
26
28
  jwt_signing_key: Optional[str] = None
File without changes
oxutils/users/admin.py ADDED
@@ -0,0 +1,3 @@
1
+ from django.contrib import admin
2
+
3
+ # Register your models here.
oxutils/users/apps.py ADDED
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class UsersConfig(AppConfig):
5
+ default_auto_field = 'django.db.models.BigAutoField'
6
+ name = 'users'
File without changes
@@ -0,0 +1,88 @@
1
+ from django.db import models
2
+
3
+ from django.contrib.auth.models import AbstractUser, BaseUserManager
4
+ from django.db import models
5
+ from django.utils.translation import gettext_lazy as _
6
+ from safedelete.models import SafeDeleteModel
7
+ from safedelete.models import SOFT_DELETE_CASCADE
8
+ from auditlog.registry import auditlog
9
+ from oxutils.models import BaseModelMixin
10
+
11
+
12
+
13
+ class UserManager(BaseUserManager):
14
+ """
15
+ Gestionnaire personnalisé pour le modèle User
16
+ """
17
+ def create_user(self, email, oxi_id, **extra_fields):
18
+ """
19
+ Crée et sauvegarde un utilisateur avec l'email et l'oxi_id donnés.
20
+ """
21
+ if not email:
22
+ raise ValueError(_('The Email field must be set'))
23
+ if not oxi_id:
24
+ raise ValueError(_('The oxi_id field must be set'))
25
+
26
+ email = self.normalize_email(email)
27
+ user = self.model(email=email, oxi_id=oxi_id, **extra_fields)
28
+ user.save(using=self._db)
29
+ return user
30
+
31
+ def create_superuser(self, email, oxi_id, **extra_fields):
32
+ """
33
+ Crée et sauvegarde un superutilisateur avec l'email et l'oxi_id donnés.
34
+ """
35
+ extra_fields.setdefault('is_staff', True)
36
+ extra_fields.setdefault('is_superuser', True)
37
+ extra_fields.setdefault('is_active', True)
38
+
39
+ if extra_fields.get('is_staff') is not True:
40
+ raise ValueError(_('Superuser must have is_staff=True.'))
41
+ if extra_fields.get('is_superuser') is not True:
42
+ raise ValueError(_('Superuser must have is_superuser=True.'))
43
+
44
+ return self.create_user(email, oxi_id, **extra_fields)
45
+
46
+
47
+ class User(AbstractUser, SafeDeleteModel, BaseModelMixin):
48
+ """
49
+ Modèle d'utilisateur personnalisé qui utilise l'email comme identifiant unique
50
+ et intègre la suppression sécurisée (soft delete)
51
+ """
52
+ _safedelete_policy = SOFT_DELETE_CASCADE
53
+
54
+ # Suppression du champ username qui est obligatoire dans AbstractUser
55
+ username = None
56
+ password = None # Don't need password
57
+
58
+ oxi_id = models.UUIDField(unique=True) # id venant de auth.oxi.com
59
+ email = models.EmailField(unique=True)
60
+ is_active = models.BooleanField(default=True)
61
+ subscription_plan = models.CharField(max_length=255, null=True, blank=True)
62
+ subscription_status = models.CharField(max_length=255, null=True, blank=True)
63
+ subscription_end_date = models.DateTimeField(null=True, blank=True)
64
+
65
+ USERNAME_FIELD = "email"
66
+ REQUIRED_FIELDS = []
67
+
68
+ def __str__(self):
69
+ return self.email
70
+
71
+ objects = UserManager()
72
+
73
+ class Meta:
74
+ verbose_name = _('utilisateur')
75
+ verbose_name_plural = _('utilisateurs')
76
+ ordering = ['-created_at']
77
+ indexes = [
78
+ models.Index(fields=['oxi_id']),
79
+ models.Index(fields=['email']),
80
+ ]
81
+
82
+ def __str__(self):
83
+ return self.email
84
+
85
+
86
+
87
+ # Enregistrement du modèle User pour l'audit logging
88
+ auditlog.register(User)
oxutils/users/tests.py ADDED
@@ -0,0 +1,3 @@
1
+ from django.test import TestCase
2
+
3
+ # Create your tests here.
oxutils/users/utils.py ADDED
@@ -0,0 +1,15 @@
1
+ from django.contrib.auth import get_user_model
2
+
3
+
4
+ def update_user(oxid: str, data: dict):
5
+ user = get_user_model().objects.get(oxi_id=oxid)
6
+ changes = False
7
+ if data:
8
+ for key, value in data.items():
9
+ if hasattr(user, key):
10
+ setattr(user, key, value)
11
+ changes = True
12
+ if changes:
13
+ user.save()
14
+
15
+ return user
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oxutils
3
- Version: 0.1.2
3
+ Version: 0.1.5
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
@@ -13,7 +13,6 @@ Classifier: Intended Audience :: Developers
13
13
  Classifier: License :: OSI Approved :: Apache Software License
14
14
  Classifier: Operating System :: OS Independent
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
18
  Classifier: Topic :: Internet :: WWW/HTTP
@@ -22,7 +21,6 @@ Requires-Dist: celery>=5.5.3
22
21
  Requires-Dist: cryptography>=46.0.3
23
22
  Requires-Dist: django-auditlog>=3.3.0
24
23
  Requires-Dist: django-celery-results>=2.6.0
25
- Requires-Dist: django-cid>=3.0
26
24
  Requires-Dist: django-extensions>=4.1
27
25
  Requires-Dist: django-ninja>=1.5.0
28
26
  Requires-Dist: django-ninja-extra>=0.30.6
@@ -32,7 +30,7 @@ Requires-Dist: jwcrypto>=1.5.6
32
30
  Requires-Dist: pydantic-settings>=2.12.0
33
31
  Requires-Dist: pyjwt>=2.10.1
34
32
  Requires-Dist: requests>=2.32.5
35
- Requires-Python: >=3.11
33
+ Requires-Python: >=3.12
36
34
  Project-URL: Changelog, https://github.com/oxiliere/oxutils/blob/main/CHANGELOG.md
37
35
  Project-URL: Documentation, https://github.com/oxiliere/oxutils/tree/main/docs
38
36
  Project-URL: Homepage, https://github.com/oxiliere/oxutils
@@ -45,9 +43,9 @@ Description-Content-Type: text/markdown
45
43
  **Production-ready utilities for Django applications in the Oxiliere ecosystem.**
46
44
 
47
45
  [![PyPI version](https://img.shields.io/pypi/v/oxutils.svg)](https://pypi.org/project/oxutils/)
48
- [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/)
46
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/)
49
47
  [![Django 5.0+](https://img.shields.io/badge/django-5.0+-green.svg)](https://www.djangoproject.com/)
50
- [![Tests](https://img.shields.io/badge/tests-126%20passed-success.svg)](tests/)
48
+ [![Tests](https://img.shields.io/badge/tests-201%20passed-success.svg)](tests/)
51
49
  [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
52
50
  [![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff)
53
51
 
@@ -55,11 +53,15 @@ Description-Content-Type: text/markdown
55
53
 
56
54
  - 🔐 **JWT Authentication** - RS256 with JWKS caching
57
55
  - 📦 **S3 Storage** - Static, media, private, and log backends
58
- - 📝 **Structured Logging** - JSON logs with correlation IDs
56
+ - 📝 **Structured Logging** - JSON logs with automatic request tracking
59
57
  - 🔍 **Audit System** - Change tracking with S3 export
60
58
  - ⚙️ **Celery Integration** - Pre-configured task processing
61
59
  - 🛠️ **Django Mixins** - UUID, timestamps, user tracking
62
60
  - ⚡ **Custom Exceptions** - Standardized API errors
61
+ - 🎨 **Context Processors** - Site name and domain for templates
62
+ - 💱 **Currency Module** - Multi-source exchange rates (BCC/OXR)
63
+ - 📄 **PDF Generation** - WeasyPrint integration for Django
64
+ - 🏢 **Multi-Tenant** - PostgreSQL schema-based isolation
63
65
 
64
66
  ---
65
67
 
@@ -82,12 +84,12 @@ uv add oxutils
82
84
  from oxutils.conf import UTILS_APPS, AUDIT_MIDDLEWARE
83
85
 
84
86
  INSTALLED_APPS = [
85
- *UTILS_APPS, # structlog, auditlog, cid, celery_results
87
+ *UTILS_APPS, # structlog, auditlog, celery_results
86
88
  # your apps...
87
89
  ]
88
90
 
89
91
  MIDDLEWARE = [
90
- *AUDIT_MIDDLEWARE, # CID, Auditlog, RequestMiddleware
92
+ *AUDIT_MIDDLEWARE, # RequestMiddleware, Auditlog
91
93
  # your middleware...
92
94
  ]
93
95
  ```
@@ -126,10 +128,22 @@ class Product(BaseModelMixin): # UUID + timestamps + is_active
126
128
  # Custom Exceptions
127
129
  from oxutils.exceptions import NotFoundException
128
130
  raise NotFoundException(detail="User not found")
131
+
132
+ # Context Processors
133
+ # settings.py
134
+ TEMPLATES = [{
135
+ 'OPTIONS': {
136
+ 'context_processors': [
137
+ 'oxutils.context.site_name_processor.site_name',
138
+ ],
139
+ },
140
+ }]
141
+ # Now {{ site_name }} and {{ site_domain }} are available in templates
129
142
  ```
130
143
 
131
144
  ## Documentation
132
145
 
146
+ ### Core Modules
133
147
  - **[Settings](docs/settings.md)** - Configuration reference
134
148
  - **[JWT](docs/jwt.md)** - Authentication
135
149
  - **[S3](docs/s3.md)** - Storage backends
@@ -138,9 +152,14 @@ raise NotFoundException(detail="User not found")
138
152
  - **[Mixins](docs/mixins.md)** - Model/service mixins
139
153
  - **[Celery](docs/celery.md)** - Task processing
140
154
 
155
+ ### Additional Modules
156
+ - **[Currency](docs/currency.md)** - Exchange rates management
157
+ - **[PDF](docs/pdf.md)** - PDF generation with WeasyPrint
158
+ - **[Oxiliere](docs/oxiliere.md)** - Multi-tenant architecture
159
+
141
160
  ## Requirements
142
161
 
143
- - Python 3.11+
162
+ - Python 3.12+
144
163
  - Django 5.0+
145
164
  - PostgreSQL (recommended)
146
165
 
@@ -150,7 +169,7 @@ raise NotFoundException(detail="User not found")
150
169
  git clone https://github.com/oxiliere/oxutils.git
151
170
  cd oxutils
152
171
  uv sync
153
- uv run pytest # 126 tests
172
+ uv run pytest # 201 tests passing, 4 skipped
154
173
  ```
155
174
 
156
175
  ### Creating Migrations
@@ -165,6 +184,19 @@ uv run make_migrations.py
165
184
 
166
185
  See [MIGRATIONS.md](MIGRATIONS.md) for detailed documentation.
167
186
 
187
+ ## Optional Dependencies
188
+
189
+ ```bash
190
+ # Multi-tenant support
191
+ uv add oxutils[oxiliere]
192
+
193
+ # PDF generation
194
+ uv add oxutils[pdf]
195
+
196
+ # Development tools
197
+ uv add oxutils[dev]
198
+ ```
199
+
168
200
  ## Advanced Examples
169
201
 
170
202
  ### JWT with Django Ninja
@@ -199,6 +231,55 @@ export = export_logs_from_date(from_date=from_date)
199
231
  print(f"Exported to {export.data.url}")
200
232
  ```
201
233
 
234
+ ### Currency Exchange Rates
235
+
236
+ ```python
237
+ from oxutils.currency.models import CurrencyState
238
+
239
+ # Sync rates from BCC (with OXR fallback)
240
+ state = CurrencyState.sync()
241
+
242
+ # Get latest rates
243
+ latest = CurrencyState.objects.latest()
244
+ usd_rate = latest.currencies.get(code='USD').rate
245
+ eur_rate = latest.currencies.get(code='EUR').rate
246
+ ```
247
+
248
+ ### PDF Generation
249
+
250
+ ```python
251
+ from oxutils.pdf.printer import Printer
252
+ from oxutils.pdf.views import WeasyTemplateView
253
+
254
+ # Standalone PDF generation
255
+ printer = Printer(
256
+ template_name='invoice.html',
257
+ context={'invoice': invoice},
258
+ stylesheets=['css/invoice.css']
259
+ )
260
+ pdf_bytes = printer.write_pdf()
261
+
262
+ # Class-based view
263
+ class InvoicePDFView(WeasyTemplateView):
264
+ template_name = 'invoice.html'
265
+ pdf_filename = 'invoice.pdf'
266
+ pdf_stylesheets = ['css/invoice.css']
267
+ ```
268
+
269
+ ### Multi-Tenant Setup
270
+
271
+ ```python
272
+ # settings.py
273
+ TENANT_MODEL = "oxiliere.Tenant"
274
+ MIDDLEWARE = [
275
+ 'oxutils.oxiliere.middleware.TenantMainMiddleware', # First!
276
+ # other middleware...
277
+ ]
278
+
279
+ # All requests must include X-Organization-ID header
280
+ # Data is automatically isolated per tenant schema
281
+ ```
282
+
202
283
  ## License
203
284
 
204
285
  Apache 2.0 License - see [LICENSE](LICENSE)