oxutils 0.1.2__py3-none-any.whl → 0.1.6__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.6.dist-info}/METADATA +99 -11
  52. oxutils-0.1.6.dist-info/RECORD +88 -0
  53. {oxutils-0.1.2.dist-info → oxutils-0.1.6.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.6
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,12 +30,19 @@ 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-Dist: bcc-rates>=1.1.0 ; extra == 'currency'
34
+ Requires-Dist: django-cacheops>=7.2 ; extra == 'oxiliere'
35
+ Requires-Dist: django-tenants>=3.9.0 ; extra == 'oxiliere'
36
+ Requires-Dist: weasyprint>=67.0 ; extra == 'pdf'
37
+ Requires-Python: >=3.12
36
38
  Project-URL: Changelog, https://github.com/oxiliere/oxutils/blob/main/CHANGELOG.md
37
39
  Project-URL: Documentation, https://github.com/oxiliere/oxutils/tree/main/docs
38
40
  Project-URL: Homepage, https://github.com/oxiliere/oxutils
39
41
  Project-URL: Issues, https://github.com/oxiliere/oxutils/issues
40
42
  Project-URL: Repository, https://github.com/oxiliere/oxutils
43
+ Provides-Extra: currency
44
+ Provides-Extra: oxiliere
45
+ Provides-Extra: pdf
41
46
  Description-Content-Type: text/markdown
42
47
 
43
48
  # OxUtils
@@ -45,9 +50,9 @@ Description-Content-Type: text/markdown
45
50
  **Production-ready utilities for Django applications in the Oxiliere ecosystem.**
46
51
 
47
52
  [![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/)
53
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/)
49
54
  [![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/)
55
+ [![Tests](https://img.shields.io/badge/tests-201%20passed-success.svg)](tests/)
51
56
  [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
52
57
  [![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff)
53
58
 
@@ -55,11 +60,15 @@ Description-Content-Type: text/markdown
55
60
 
56
61
  - 🔐 **JWT Authentication** - RS256 with JWKS caching
57
62
  - 📦 **S3 Storage** - Static, media, private, and log backends
58
- - 📝 **Structured Logging** - JSON logs with correlation IDs
63
+ - 📝 **Structured Logging** - JSON logs with automatic request tracking
59
64
  - 🔍 **Audit System** - Change tracking with S3 export
60
65
  - ⚙️ **Celery Integration** - Pre-configured task processing
61
66
  - 🛠️ **Django Mixins** - UUID, timestamps, user tracking
62
67
  - ⚡ **Custom Exceptions** - Standardized API errors
68
+ - 🎨 **Context Processors** - Site name and domain for templates
69
+ - 💱 **Currency Module** - Multi-source exchange rates (BCC/OXR)
70
+ - 📄 **PDF Generation** - WeasyPrint integration for Django
71
+ - 🏢 **Multi-Tenant** - PostgreSQL schema-based isolation
63
72
 
64
73
  ---
65
74
 
@@ -82,12 +91,12 @@ uv add oxutils
82
91
  from oxutils.conf import UTILS_APPS, AUDIT_MIDDLEWARE
83
92
 
84
93
  INSTALLED_APPS = [
85
- *UTILS_APPS, # structlog, auditlog, cid, celery_results
94
+ *UTILS_APPS, # structlog, auditlog, celery_results
86
95
  # your apps...
87
96
  ]
88
97
 
89
98
  MIDDLEWARE = [
90
- *AUDIT_MIDDLEWARE, # CID, Auditlog, RequestMiddleware
99
+ *AUDIT_MIDDLEWARE, # RequestMiddleware, Auditlog
91
100
  # your middleware...
92
101
  ]
93
102
  ```
@@ -126,10 +135,22 @@ class Product(BaseModelMixin): # UUID + timestamps + is_active
126
135
  # Custom Exceptions
127
136
  from oxutils.exceptions import NotFoundException
128
137
  raise NotFoundException(detail="User not found")
138
+
139
+ # Context Processors
140
+ # settings.py
141
+ TEMPLATES = [{
142
+ 'OPTIONS': {
143
+ 'context_processors': [
144
+ 'oxutils.context.site_name_processor.site_name',
145
+ ],
146
+ },
147
+ }]
148
+ # Now {{ site_name }} and {{ site_domain }} are available in templates
129
149
  ```
130
150
 
131
151
  ## Documentation
132
152
 
153
+ ### Core Modules
133
154
  - **[Settings](docs/settings.md)** - Configuration reference
134
155
  - **[JWT](docs/jwt.md)** - Authentication
135
156
  - **[S3](docs/s3.md)** - Storage backends
@@ -138,9 +159,14 @@ raise NotFoundException(detail="User not found")
138
159
  - **[Mixins](docs/mixins.md)** - Model/service mixins
139
160
  - **[Celery](docs/celery.md)** - Task processing
140
161
 
162
+ ### Additional Modules
163
+ - **[Currency](docs/currency.md)** - Exchange rates management
164
+ - **[PDF](docs/pdf.md)** - PDF generation with WeasyPrint
165
+ - **[Oxiliere](docs/oxiliere.md)** - Multi-tenant architecture
166
+
141
167
  ## Requirements
142
168
 
143
- - Python 3.11+
169
+ - Python 3.12+
144
170
  - Django 5.0+
145
171
  - PostgreSQL (recommended)
146
172
 
@@ -150,7 +176,7 @@ raise NotFoundException(detail="User not found")
150
176
  git clone https://github.com/oxiliere/oxutils.git
151
177
  cd oxutils
152
178
  uv sync
153
- uv run pytest # 126 tests
179
+ uv run pytest # 201 tests passing, 4 skipped
154
180
  ```
155
181
 
156
182
  ### Creating Migrations
@@ -165,6 +191,19 @@ uv run make_migrations.py
165
191
 
166
192
  See [MIGRATIONS.md](MIGRATIONS.md) for detailed documentation.
167
193
 
194
+ ## Optional Dependencies
195
+
196
+ ```bash
197
+ # Multi-tenant support
198
+ uv add oxutils[oxiliere]
199
+
200
+ # PDF generation
201
+ uv add oxutils[pdf]
202
+
203
+ # Development tools
204
+ uv add oxutils[dev]
205
+ ```
206
+
168
207
  ## Advanced Examples
169
208
 
170
209
  ### JWT with Django Ninja
@@ -199,6 +238,55 @@ export = export_logs_from_date(from_date=from_date)
199
238
  print(f"Exported to {export.data.url}")
200
239
  ```
201
240
 
241
+ ### Currency Exchange Rates
242
+
243
+ ```python
244
+ from oxutils.currency.models import CurrencyState
245
+
246
+ # Sync rates from BCC (with OXR fallback)
247
+ state = CurrencyState.sync()
248
+
249
+ # Get latest rates
250
+ latest = CurrencyState.objects.latest()
251
+ usd_rate = latest.currencies.get(code='USD').rate
252
+ eur_rate = latest.currencies.get(code='EUR').rate
253
+ ```
254
+
255
+ ### PDF Generation
256
+
257
+ ```python
258
+ from oxutils.pdf.printer import Printer
259
+ from oxutils.pdf.views import WeasyTemplateView
260
+
261
+ # Standalone PDF generation
262
+ printer = Printer(
263
+ template_name='invoice.html',
264
+ context={'invoice': invoice},
265
+ stylesheets=['css/invoice.css']
266
+ )
267
+ pdf_bytes = printer.write_pdf()
268
+
269
+ # Class-based view
270
+ class InvoicePDFView(WeasyTemplateView):
271
+ template_name = 'invoice.html'
272
+ pdf_filename = 'invoice.pdf'
273
+ pdf_stylesheets = ['css/invoice.css']
274
+ ```
275
+
276
+ ### Multi-Tenant Setup
277
+
278
+ ```python
279
+ # settings.py
280
+ TENANT_MODEL = "oxiliere.Tenant"
281
+ MIDDLEWARE = [
282
+ 'oxutils.oxiliere.middleware.TenantMainMiddleware', # First!
283
+ # other middleware...
284
+ ]
285
+
286
+ # All requests must include X-Organization-ID header
287
+ # Data is automatically isolated per tenant schema
288
+ ```
289
+
202
290
  ## License
203
291
 
204
292
  Apache 2.0 License - see [LICENSE](LICENSE)