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 ADDED
@@ -0,0 +1,23 @@
1
+ """OxUtils - Production-ready utilities for Django applications.
2
+
3
+ This package provides:
4
+ - JWT authentication with JWKS support
5
+ - S3 storage backends (static, media, private, logs)
6
+ - Structured logging with correlation IDs
7
+ - Audit system with S3 export
8
+ - Celery integration
9
+ - Django model mixins
10
+ - Custom exceptions
11
+ """
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ from oxutils.settings import oxi_settings
16
+ from oxutils.conf import UTILS_APPS, AUDIT_MIDDLEWARE
17
+
18
+ __all__ = [
19
+ "oxi_settings",
20
+ "UTILS_APPS",
21
+ "AUDIT_MIDDLEWARE",
22
+ "__version__",
23
+ ]
oxutils/apps.py ADDED
@@ -0,0 +1,14 @@
1
+ from django.apps import AppConfig
2
+ from django.utils.translation import gettext_lazy as _
3
+
4
+
5
+
6
+ class OxutilsConfig(AppConfig):
7
+ default_auto_field = 'django.db.models.BigAutoField'
8
+ name = 'oxutils'
9
+ verbose_name = _("Oxiliere Utilities")
10
+
11
+ def ready(self):
12
+ import oxutils.logger.receivers
13
+
14
+ return super().ready()
File without changes
oxutils/audit/apps.py ADDED
@@ -0,0 +1,12 @@
1
+ from django.apps import AppConfig
2
+ from django.utils.translation import gettext_lazy as _
3
+
4
+
5
+
6
+ class OxutilsConfig(AppConfig):
7
+ default_auto_field = 'django.db.models.BigAutoField'
8
+ name = 'oxutils_export'
9
+ verbose_name = _("Oxutils Export")
10
+
11
+ def ready(self):
12
+ return super().ready()
@@ -0,0 +1,229 @@
1
+ """
2
+ Export utilities for audit logs.
3
+
4
+ This module provides utilities to export LogEntry records from auditlog,
5
+ compress them into ZIP files, and save them to LogExportState.
6
+ """
7
+ import io
8
+ import json
9
+ import zipfile
10
+ from datetime import datetime
11
+ from typing import Optional
12
+
13
+ from django.apps import apps
14
+ from django.core.files.base import ContentFile
15
+ from django.db.models import QuerySet
16
+ from django.utils import timezone
17
+
18
+ from oxutils.audit.models import LogExportState
19
+
20
+
21
+ def get_logentry_model():
22
+ """Get the LogEntry model from auditlog."""
23
+ return apps.get_model('auditlog', 'LogEntry')
24
+
25
+
26
+ def export_logs_from_date(
27
+ from_date: datetime,
28
+ to_date: Optional[datetime] = None,
29
+ batch_size: int = 5000
30
+ ) -> LogExportState:
31
+ """
32
+ Export audit logs from a specific date, compress them, and save to LogExportState.
33
+ Optimized for S3 storage with streaming and minimal memory usage.
34
+
35
+ Args:
36
+ from_date: Start date for log export (inclusive)
37
+ to_date: End date for log export (inclusive). If None, uses current time.
38
+ batch_size: Number of records to process at a time (default: 5000)
39
+
40
+ Returns:
41
+ LogExportState: The created export state with the compressed data
42
+
43
+ Raises:
44
+ Exception: If export fails, the LogExportState status will be set to FAILED
45
+ """
46
+ if to_date is None:
47
+ to_date = timezone.now()
48
+
49
+ # Create the export state
50
+ export_state = LogExportState.create(size=0)
51
+
52
+ try:
53
+ # Get LogEntry model
54
+ LogEntry = get_logentry_model()
55
+
56
+ # Query logs within date range - use select_related for optimization
57
+ logs_queryset = LogEntry.objects.filter(
58
+ timestamp__gte=from_date,
59
+ timestamp__lte=to_date
60
+ ).select_related('content_type', 'actor').order_by('timestamp')
61
+
62
+ # Create ZIP file in memory with optimal compression
63
+ zip_buffer = io.BytesIO()
64
+
65
+ with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as zip_file:
66
+ # Export logs in batches using iterator to avoid loading all in memory
67
+ total_exported = 0
68
+ batch_number = 1
69
+ batch_logs = []
70
+
71
+ # Use iterator() to stream results from database
72
+ for log in logs_queryset.iterator(chunk_size=batch_size):
73
+ batch_logs.append(_serialize_log_entry(log))
74
+
75
+ # Write batch when it reaches batch_size
76
+ if len(batch_logs) >= batch_size:
77
+ filename = f'logs_batch_{batch_number:04d}.json'
78
+ # Use separators to minimize JSON size
79
+ zip_file.writestr(
80
+ filename,
81
+ json.dumps(batch_logs, separators=(',', ':'))
82
+ )
83
+ total_exported += len(batch_logs)
84
+ batch_number += 1
85
+ batch_logs = []
86
+
87
+ # Write remaining logs
88
+ if batch_logs:
89
+ filename = f'logs_batch_{batch_number:04d}.json'
90
+ zip_file.writestr(
91
+ filename,
92
+ json.dumps(batch_logs, separators=(',', ':'))
93
+ )
94
+ total_exported += len(batch_logs)
95
+
96
+ # Export metadata at the end (we now have accurate count)
97
+ metadata = {
98
+ 'export_date': timezone.now().isoformat(),
99
+ 'from_date': from_date.isoformat(),
100
+ 'to_date': to_date.isoformat(),
101
+ 'total_records': total_exported,
102
+ 'batch_size': batch_size,
103
+ 'total_batches': batch_number
104
+ }
105
+ zip_file.writestr('metadata.json', json.dumps(metadata, separators=(',', ':')))
106
+
107
+ # Get the ZIP file size and content
108
+ zip_buffer.seek(0)
109
+ zip_content = zip_buffer.getvalue()
110
+ zip_size = len(zip_content)
111
+
112
+ # Save to LogExportState - S3 upload happens here
113
+ filename = f'audit_logs_{from_date.strftime("%Y%m%d")}_{to_date.strftime("%Y%m%d")}.zip'
114
+ export_state.data.save(filename, ContentFile(zip_content), save=False)
115
+ export_state.size = zip_size
116
+ export_state.set_success()
117
+
118
+ return export_state
119
+
120
+ except Exception as e:
121
+ export_state.set_failed()
122
+ raise e
123
+
124
+
125
+ def _serialize_log_entry(log) -> dict:
126
+ """
127
+ Serialize a single LogEntry to a dictionary (optimized version).
128
+
129
+ Args:
130
+ log: LogEntry object
131
+
132
+ Returns:
133
+ dict: Serialized log entry with minimal overhead
134
+ """
135
+ return {
136
+ 'id': log.id,
137
+ 'timestamp': log.timestamp.isoformat() if log.timestamp else None,
138
+ 'action': log.action,
139
+ 'content_type': {
140
+ 'app_label': log.content_type.app_label if log.content_type else None,
141
+ 'model': log.content_type.model if log.content_type else None,
142
+ } if log.content_type else None,
143
+ 'object_pk': log.object_pk,
144
+ 'object_repr': log.object_repr,
145
+ 'changes': log.changes,
146
+ 'actor': {
147
+ 'id': log.actor.id if log.actor else None,
148
+ 'username': str(log.actor) if log.actor else None,
149
+ } if log.actor else None,
150
+ 'remote_addr': log.remote_addr,
151
+ 'additional_data': log.additional_data if hasattr(log, 'additional_data') else None,
152
+ }
153
+
154
+
155
+ def serialize_log_entries(queryset: QuerySet) -> list:
156
+ """
157
+ Serialize LogEntry queryset to a list of dictionaries.
158
+
159
+ Args:
160
+ queryset: QuerySet of LogEntry objects
161
+
162
+ Returns:
163
+ list: List of serialized log entries
164
+ """
165
+ return [_serialize_log_entry(log) for log in queryset]
166
+
167
+
168
+ def export_logs_since_last_export(batch_size: int = 5000) -> Optional[LogExportState]:
169
+ """
170
+ Export logs since the last successful export.
171
+
172
+ Args:
173
+ batch_size: Number of records to process at a time
174
+
175
+ Returns:
176
+ LogExportState or None: The created export state, or None if no previous export exists
177
+ """
178
+ # Get the last successful export
179
+ last_export = LogExportState.objects.filter(
180
+ status='success',
181
+ last_export_date__isnull=False
182
+ ).order_by('-last_export_date').first()
183
+
184
+ if last_export:
185
+ from_date = last_export.last_export_date
186
+ else:
187
+ # If no previous export, start from the earliest log
188
+ LogEntry = get_logentry_model()
189
+ earliest_log = LogEntry.objects.order_by('timestamp').first()
190
+
191
+ if not earliest_log:
192
+ return None
193
+
194
+ from_date = earliest_log.timestamp
195
+
196
+ return export_logs_from_date(from_date=from_date, batch_size=batch_size)
197
+
198
+
199
+ def get_export_statistics() -> dict:
200
+ """
201
+ Get statistics about log exports.
202
+
203
+ Returns:
204
+ dict: Statistics including total exports, successful exports, failed exports, etc.
205
+ """
206
+ from oxutils.enums.audit import ExportStatus
207
+
208
+ total_exports = LogExportState.objects.count()
209
+ successful_exports = LogExportState.objects.filter(status=ExportStatus.SUCCESS).count()
210
+ failed_exports = LogExportState.objects.filter(status=ExportStatus.FAILED).count()
211
+ pending_exports = LogExportState.objects.filter(status=ExportStatus.PENDING).count()
212
+
213
+ last_export = LogExportState.objects.filter(
214
+ status=ExportStatus.SUCCESS
215
+ ).order_by('-last_export_date').first()
216
+
217
+ total_size = sum(
218
+ export.size for export in LogExportState.objects.filter(status=ExportStatus.SUCCESS)
219
+ )
220
+
221
+ return {
222
+ 'total_exports': total_exports,
223
+ 'successful_exports': successful_exports,
224
+ 'failed_exports': failed_exports,
225
+ 'pending_exports': pending_exports,
226
+ 'last_export_date': last_export.last_export_date if last_export else None,
227
+ 'total_size_bytes': total_size,
228
+ 'total_size_mb': round(total_size / (1024 * 1024), 2),
229
+ }
oxutils/audit/masks.py ADDED
@@ -0,0 +1,97 @@
1
+ # Auditlog masks
2
+
3
+
4
+ def number_mask(value: str) -> str:
5
+ """Mask a number showing only the last 4 digits.
6
+
7
+ Args:
8
+ value: The number string to mask
9
+
10
+ Returns:
11
+ Masked string with format: ****1234
12
+ """
13
+ if not value:
14
+ return ""
15
+
16
+ value_str = str(value).strip()
17
+ if len(value_str) <= 4:
18
+ return "*" * len(value_str)
19
+
20
+ return "****" + value_str[-4:]
21
+
22
+
23
+ def phone_number_mask(value: str) -> str:
24
+ """Mask a phone number showing only the last 4 digits.
25
+
26
+ Args:
27
+ value: The phone number to mask
28
+
29
+ Returns:
30
+ Masked phone number with format: ****1234
31
+ """
32
+ if not value:
33
+ return ""
34
+
35
+ # Remove common phone number formatting characters
36
+ cleaned = str(value).replace(" ", "").replace("-", "").replace("(", "").replace(")", "").replace("+", "")
37
+
38
+ if len(cleaned) <= 4:
39
+ return "*" * len(cleaned)
40
+
41
+ return "****" + cleaned[-4:]
42
+
43
+
44
+ def credit_card_mask(value: str) -> str:
45
+ """Mask a credit card number showing only the last 4 digits.
46
+
47
+ Args:
48
+ value: The credit card number to mask
49
+
50
+ Returns:
51
+ Masked credit card with format: ****1234
52
+ """
53
+ if not value:
54
+ return ""
55
+
56
+ # Remove spaces and dashes from credit card number
57
+ cleaned = str(value).replace(" ", "").replace("-", "")
58
+
59
+ if len(cleaned) <= 4:
60
+ return "*" * len(cleaned)
61
+
62
+ return "****" + cleaned[-4:]
63
+
64
+ def email_mask(value: str) -> str:
65
+ """Mask an email address showing only the first character and domain.
66
+
67
+ Args:
68
+ value: The email address to mask
69
+
70
+ Returns:
71
+ Masked email with format: j***@example.com
72
+ """
73
+ if not value:
74
+ return ""
75
+
76
+ value_str = str(value).strip()
77
+
78
+ if "@" not in value_str:
79
+ # Not a valid email format, mask most of it
80
+ if len(value_str) <= 2:
81
+ return "*" * len(value_str)
82
+ return value_str[0] + "*" * (len(value_str) - 1)
83
+
84
+ local, domain = value_str.rsplit("@", 1)
85
+
86
+ if not local:
87
+ return "***@" + domain
88
+
89
+ if len(local) == 1:
90
+ masked_local = "*"
91
+ elif len(local) == 2:
92
+ masked_local = local[0] + "*"
93
+ else:
94
+ masked_local = local[0] + "*" * (len(local) - 1)
95
+
96
+ return masked_local + "@" + domain
97
+
@@ -0,0 +1,75 @@
1
+ from django.utils.translation import gettext_lazy as _
2
+ from django.utils import timezone
3
+ from django.db import models, transaction
4
+ from oxutils.enums.audit import ExportStatus
5
+ from oxutils.models.base import TimestampMixin
6
+ from oxutils.s3.storages import LogStorage
7
+
8
+
9
+
10
+
11
+ class LogExportHistory(models.Model):
12
+ state = models.ForeignKey(
13
+ "LogExportState",
14
+ related_name='log_histories',
15
+ on_delete=models.CASCADE
16
+ )
17
+ status = models.CharField(
18
+ default=ExportStatus.PENDING,
19
+ choices=(
20
+ (ExportStatus.FAILED, _("Failed")),
21
+ (ExportStatus.PENDING, _('Pending')),
22
+ (ExportStatus.SUCCESS, _('Success'))
23
+ )
24
+ )
25
+ created_at = models.DateTimeField(
26
+ auto_now_add=True,
27
+ help_text="Date and time when this record was created"
28
+ )
29
+
30
+
31
+ class LogExportState(TimestampMixin):
32
+ last_export_date = models.DateTimeField(null=True)
33
+ status = models.CharField(
34
+ default=ExportStatus.PENDING,
35
+ choices=(
36
+ (ExportStatus.FAILED, _("Failed")),
37
+ (ExportStatus.PENDING, _('Pending')),
38
+ (ExportStatus.SUCCESS, _('Success'))
39
+ )
40
+ )
41
+ data = models.FileField(storage=LogStorage())
42
+ size = models.BigIntegerField()
43
+
44
+ @classmethod
45
+ def create(cls, size: int = 0):
46
+ return cls.objects.create(
47
+ status=ExportStatus.PENDING,
48
+ size=size
49
+ )
50
+
51
+ @transaction.atomic
52
+ def set_success(self):
53
+ self.status = ExportStatus.SUCCESS
54
+ self.last_export_date = timezone.now()
55
+ LogExportHistory.objects.create(
56
+ state=self,
57
+ status=ExportStatus.SUCCESS
58
+ )
59
+ self.save(update_fields=(
60
+ 'status',
61
+ 'last_export_date',
62
+ 'updated_at'
63
+ ))
64
+
65
+ @transaction.atomic
66
+ def set_failed(self):
67
+ self.status = ExportStatus.FAILED
68
+ self.save(update_fields=(
69
+ 'status',
70
+ 'updated_at'
71
+ ))
72
+ LogExportHistory.objects.create(
73
+ state=self,
74
+ status=ExportStatus.FAILED
75
+ )
@@ -0,0 +1,19 @@
1
+ # Oxiliere Audit settings
2
+
3
+ AUDITLOG_DISABLE_REMOTE_ADDR = False
4
+ AUDITLOG_MASK_TRACKING_FIELDS = (
5
+ "password",
6
+ "api_key",
7
+ "secret_token",
8
+ "token",
9
+ )
10
+
11
+ AUDITLOG_EXCLUDE_TRACKING_FIELDS = (
12
+ "created_at",
13
+ "updated_at",
14
+ )
15
+
16
+ CID_GENERATE = False
17
+
18
+ AUDITLOG_CID_GETTER = "cid.locals.get_cid"
19
+ AUDITLOG_LOGENTRY_MODEL = "auditlog.LogEntry"
@@ -0,0 +1 @@
1
+ from .base import celery_app
oxutils/celery/base.py ADDED
@@ -0,0 +1,98 @@
1
+ import logging
2
+
3
+ import structlog
4
+ from celery import Celery
5
+ from celery.signals import setup_logging
6
+ from django.conf import settings
7
+ from django_structlog.celery.steps import DjangoStructLogInitStep
8
+
9
+
10
+
11
+
12
+
13
+ celery_app = Celery(getattr(settings, "CELERY_APP_NAME", 'oxiliere_celery'))
14
+
15
+ celery_app.config_from_object('django.conf:settings', namespace='CELERY')
16
+
17
+ # A step to initialize django-structlog
18
+ celery_app.steps['worker'].add(DjangoStructLogInitStep)
19
+
20
+ celery_app.autodiscover_tasks()
21
+
22
+
23
+ @setup_logging.connect
24
+ def receiver_setup_logging(loglevel, logfile, format, colorize, **kwargs): # pragma: no cover
25
+ logging.config.dictConfig(
26
+ {
27
+ "version": 1,
28
+ "disable_existing_loggers": False,
29
+ "formatters": {
30
+ "json_formatter": {
31
+ "()": structlog.stdlib.ProcessorFormatter,
32
+ "processor": structlog.processors.JSONRenderer(),
33
+ },
34
+ "plain_console": {
35
+ "()": structlog.stdlib.ProcessorFormatter,
36
+ "processor": structlog.dev.ConsoleRenderer(),
37
+ },
38
+ "key_value": {
39
+ "()": structlog.stdlib.ProcessorFormatter,
40
+ "processor": structlog.processors.KeyValueRenderer(
41
+ key_order=['timestamp', 'level', 'event', 'logger']
42
+ ),
43
+ },
44
+ },
45
+ "handlers": {
46
+ "console": {
47
+ "class": "logging.StreamHandler",
48
+ "formatter": "plain_console",
49
+ 'filters': ['correlation'],
50
+ },
51
+ "json_file": {
52
+ "class": "logging.handlers.WatchedFileHandler",
53
+ "filename": "logs/json.log",
54
+ "formatter": "json_formatter",
55
+ 'filters': ['correlation'],
56
+ },
57
+ "flat_line_file": {
58
+ "class": "logging.handlers.WatchedFileHandler",
59
+ "filename": "logs/flat_line.log",
60
+ "formatter": "key_value",
61
+ 'filters': ['correlation'],
62
+ },
63
+ },
64
+ 'filters': {
65
+ 'correlation': {
66
+ '()': 'cid.log.CidContextFilter'
67
+ },
68
+ },
69
+ "loggers": {
70
+ "django_structlog": {
71
+ "handlers": ["console", "flat_line_file", "json_file"],
72
+ "level": "INFO",
73
+ },
74
+ "oxiliere_log": {
75
+ "handlers": ["console", "flat_line_file", "json_file"],
76
+ "level": "INFO",
77
+ },
78
+ }
79
+ }
80
+ )
81
+
82
+ structlog.configure(
83
+ processors=[
84
+ structlog.contextvars.merge_contextvars,
85
+ structlog.stdlib.filter_by_level,
86
+ structlog.processors.TimeStamper(fmt="iso"),
87
+ structlog.stdlib.add_logger_name,
88
+ structlog.stdlib.add_log_level,
89
+ structlog.stdlib.PositionalArgumentsFormatter(),
90
+ structlog.processors.StackInfoRenderer(),
91
+ structlog.processors.format_exc_info,
92
+ structlog.processors.UnicodeDecoder(),
93
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
94
+ ],
95
+ logger_factory=structlog.stdlib.LoggerFactory(),
96
+ cache_logger_on_first_use=True,
97
+ )
98
+
@@ -0,0 +1 @@
1
+ CELERY_CACHE_BACKEND = 'default'
oxutils/conf.py ADDED
@@ -0,0 +1,12 @@
1
+ UTILS_APPS = (
2
+ 'django_structlog',
3
+ 'auditlog',
4
+ 'cid.apps.CidAppConfig',
5
+ 'django_celery_results',
6
+ )
7
+
8
+ AUDIT_MIDDLEWARE = (
9
+ 'cid.middleware.CidMiddleware',
10
+ 'auditlog.middleware.AuditlogMiddleware',
11
+ 'django_structlog.middlewares.RequestMiddleware',
12
+ )
@@ -0,0 +1 @@
1
+ from .invoices import InvoiceStatusEnum
oxutils/enums/audit.py ADDED
@@ -0,0 +1,8 @@
1
+ from enum import Enum
2
+
3
+
4
+
5
+ class ExportStatus(str, Enum):
6
+ FAILED = 'failed'
7
+ SUCCESS = 'success'
8
+ PENDING = 'pending'
@@ -0,0 +1,11 @@
1
+ from enum import Enum
2
+
3
+
4
+
5
+ class InvoiceStatusEnum(str, Enum):
6
+ DRAFT = "draft"
7
+ PENDING = "pending"
8
+ PAID = "paid"
9
+ OVERDUE = "overdue"
10
+ CANCELLED = "cancelled"
11
+ REFUNDED = 'refunded'