va7-core 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.
va7/conf/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from va7.core.config import LazySettings
2
+
3
+ settings = LazySettings()
4
+
5
+ __all__ = ["settings"]
@@ -0,0 +1,72 @@
1
+ """
2
+ VA7 global default settings.
3
+
4
+ These are the production-ready defaults that ship with VA7.
5
+ Override any of these in your Django settings.py via the VA7 dict.
6
+
7
+ Example:
8
+ # settings.py
9
+ VA7 = {
10
+ "PROJECT_NAME": "My App",
11
+ "IDENTITY": {
12
+ "ROLES": {
13
+ "ADMIN": {"label": "Admin", "is_admin": True},
14
+ "MEMBER": {"label": "Member", "is_admin": False},
15
+ },
16
+ },
17
+ }
18
+ """
19
+
20
+ # These mirror VA7_DEFAULTS in config.py but are exposed here
21
+ # for documentation and IDE autocomplete purposes.
22
+
23
+ PROJECT_NAME = "VA7 Project"
24
+ PROJECT_SLUG = "va7-project"
25
+
26
+ # Core
27
+ CORE_HEALTH_CHECK_PATH = "/health/"
28
+ CORE_SECURITY_HEADERS = True
29
+
30
+ # Base model
31
+ BASE_MODEL_USE_UUID = True
32
+ BASE_MODEL_SOFT_DELETE = True
33
+ BASE_MODEL_DEFAULT_ORDERING = "-created_at"
34
+
35
+ # Identity
36
+ IDENTITY_USER_MODEL = None
37
+ IDENTITY_USERNAME_FIELD = "email"
38
+ IDENTITY_ROLES = {}
39
+ IDENTITY_DEFAULT_ROLE = None
40
+ IDENTITY_REGISTRATION_ENABLED = True
41
+ IDENTITY_REQUIRE_EMAIL_VERIFICATION = True
42
+ IDENTITY_OTP_LENGTH = 6
43
+ IDENTITY_OTP_TTL = 900 # 15 minutes
44
+ IDENTITY_OTP_MAX_RESENDS = 3
45
+ IDENTITY_JWT_ACCESS_LIFETIME = 15 # minutes
46
+ IDENTITY_JWT_REFRESH_LIFETIME = 14 # days
47
+ IDENTITY_JWT_CUSTOM_CLAIMS = ["role"]
48
+ IDENTITY_JWT_INCLUDE_USER_IN_RESPONSE = True
49
+
50
+ # Organizations
51
+ ORG_ENABLED = False
52
+ ORG_MODEL = None
53
+ ORG_SCOPING_FIELD = "organization_id"
54
+
55
+ # Notifications
56
+ NOTIFY_ENABLED = False
57
+ NOTIFY_CHANNELS = ["IN_APP", "EMAIL"]
58
+
59
+ # Billing
60
+ BILLING_ENABLED = False
61
+ BILLING_PLANS = {}
62
+ BILLING_DEFAULT_PLAN = None
63
+ BILLING_GATED_URLS = {}
64
+
65
+ # API
66
+ API_VERSION = "v1"
67
+ API_PAGE_SIZE = 20
68
+ API_THROTTLE_ANON = "100/day"
69
+ API_THROTTLE_USER = "1000/day"
70
+
71
+ # Storage
72
+ STORAGE_BACKEND = "local"
va7/core/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """
2
+ VA7 Core — Foundation layer for Django SaaS applications.
3
+
4
+ Public API (import directly):
5
+ from va7.core import BaseModel
6
+ from va7.core import custom_exception_handler
7
+ from va7.core import SecurityHeadersMiddleware, HealthCheckMiddleware
8
+ from va7.core import emit, listen
9
+
10
+ For utilities, import from submodules:
11
+ from va7.core.utils import get_env_variable, run_in_background
12
+ from va7.core.mixins import ChangeTrackingMixin, SoftDeleteAdminMixin
13
+ """
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ # Models
19
+ "BaseModel",
20
+ # Exceptions
21
+ "custom_exception_handler",
22
+ # Middleware
23
+ "SecurityHeadersMiddleware",
24
+ "HealthCheckMiddleware",
25
+ # Events
26
+ "emit",
27
+ "listen",
28
+ ]
29
+
30
+
31
+ def __getattr__(name):
32
+ """Lazy imports to avoid Django AppRegistryNotReady at module load time."""
33
+ _lazy_imports = {
34
+ "BaseModel": "va7.core.models",
35
+ "custom_exception_handler": "va7.core.exceptions",
36
+ "SecurityHeadersMiddleware": "va7.core.middleware",
37
+ "HealthCheckMiddleware": "va7.core.middleware",
38
+ "emit": "va7.core.events",
39
+ "listen": "va7.core.events",
40
+ }
41
+
42
+ if name in _lazy_imports:
43
+ import importlib
44
+
45
+ module = importlib.import_module(_lazy_imports[name])
46
+ return getattr(module, name)
47
+
48
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
va7/core/apps.py ADDED
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class VA7CoreConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "va7.core"
7
+ verbose_name = "VA7 Core"
8
+ label = "va7_core"
va7/core/config.py ADDED
@@ -0,0 +1,175 @@
1
+ import copy
2
+ import logging
3
+ import threading
4
+
5
+ from django.core.exceptions import ImproperlyConfigured
6
+
7
+ logger = logging.getLogger("va7.core")
8
+
9
+ # Default configuration — every key is optional with sensible defaults
10
+ VA7_DEFAULTS = {
11
+ "PROJECT_NAME": "VA7 Project",
12
+ "PROJECT_SLUG": "va7-project",
13
+ "CORE": {
14
+ "HEALTH_CHECK_PATH": "/health/",
15
+ "SECURITY_HEADERS": True,
16
+ },
17
+ "BASE_MODEL": {
18
+ "USE_UUID": True,
19
+ "SOFT_DELETE": True,
20
+ "DEFAULT_ORDERING": "-created_at",
21
+ },
22
+ "IDENTITY": {
23
+ "USER_MODEL": None,
24
+ "USERNAME_FIELD": "email",
25
+ "ROLES": {},
26
+ "DEFAULT_ROLE": None,
27
+ "REGISTRATION": {
28
+ "ENABLED": True,
29
+ "REQUIRE_EMAIL_VERIFICATION": True,
30
+ },
31
+ "PASSWORD_RESET": {
32
+ "OTP_LENGTH": 6,
33
+ "OTP_TTL": 900,
34
+ "MAX_RESENDS": 3,
35
+ },
36
+ "JWT": {
37
+ "ACCESS_LIFETIME": 15,
38
+ "REFRESH_LIFETIME": 14,
39
+ "CUSTOM_CLAIMS": ["role"],
40
+ "INCLUDE_USER_IN_RESPONSE": True,
41
+ },
42
+ },
43
+ "ORG": {
44
+ "ENABLED": False,
45
+ "MODEL": None,
46
+ "SCOPING_FIELD": "organization_id",
47
+ },
48
+ "NOTIFY": {
49
+ "ENABLED": False,
50
+ "CHANNELS": ["IN_APP", "EMAIL"],
51
+ },
52
+ "BILLING": {
53
+ "ENABLED": False,
54
+ "PLANS": {},
55
+ "DEFAULT_PLAN": None,
56
+ "GATED_URLS": {},
57
+ },
58
+ "API": {
59
+ "VERSION": "v1",
60
+ "PAGE_SIZE": 20,
61
+ "THROTTLE_RATES": {
62
+ "anon": "100/day",
63
+ "user": "1000/day",
64
+ },
65
+ },
66
+ "STORAGE": {
67
+ "BACKEND": "local",
68
+ },
69
+ }
70
+
71
+
72
+ def _deep_merge(defaults: dict, overrides: dict) -> dict:
73
+ """Deep merge overrides into defaults. Overrides take precedence."""
74
+ result = copy.deepcopy(defaults)
75
+ for key, value in overrides.items():
76
+ if (
77
+ key in result
78
+ and isinstance(result[key], dict)
79
+ and isinstance(value, dict)
80
+ ):
81
+ result[key] = _deep_merge(result[key], value)
82
+ else:
83
+ result[key] = copy.deepcopy(value)
84
+ return result
85
+
86
+
87
+ class LazySettings:
88
+ """
89
+ Lazy configuration accessor for VA7 settings.
90
+
91
+ Follows Django's LazySettings pattern:
92
+ - Module-level instance (not singleton class)
93
+ - Thread-safe lazy loading
94
+ - Attribute access via __getattr__
95
+ - Dot-notation access via get()
96
+ - reset() for test isolation
97
+
98
+ Usage:
99
+ from va7.conf import settings
100
+ roles = settings.AUTH["ROLES"]
101
+ page_size = settings.get("API.PAGE_SIZE", 20)
102
+ """
103
+
104
+ _wrapped = object()
105
+
106
+ def __init__(self):
107
+ self.__dict__["_wrapped"] = self._wrapped
108
+ self.__dict__["_lock"] = threading.Lock()
109
+ self.__dict__["_loaded"] = False
110
+ self.__dict__["_config"] = None
111
+
112
+ def _setup(self):
113
+ """Load configuration from Django settings, merge with defaults."""
114
+ from django.conf import settings as django_settings
115
+
116
+ user_config = getattr(django_settings, "VA7", {})
117
+ self.__dict__["_config"] = _deep_merge(VA7_DEFAULTS, user_config)
118
+ self.__dict__["_loaded"] = True
119
+ self._validate()
120
+
121
+ def _validate(self):
122
+ """Validate required configuration on startup."""
123
+ config = self.__dict__["_config"]
124
+ if config["ORG"]["ENABLED"]:
125
+ if not config["IDENTITY"]["ROLES"]:
126
+ raise ImproperlyConfigured(
127
+ "VA7: ORG is enabled but IDENTITY.ROLES is not configured. "
128
+ "Set VA7['IDENTITY']['ROLES'] in your Django settings."
129
+ )
130
+
131
+ def _ensure_loaded(self):
132
+ """Thread-safe lazy loading."""
133
+ if not self.__dict__["_loaded"]:
134
+ with self.__dict__["_lock"]:
135
+ if not self.__dict__["_loaded"]:
136
+ self._setup()
137
+
138
+ def reset(self):
139
+ """Reset configuration state. For testing only."""
140
+ with self.__dict__["_lock"]:
141
+ self.__dict__["_loaded"] = False
142
+ self.__dict__["_config"] = None
143
+
144
+ def get(self, key_path: str, default=None):
145
+ """Get a nested config value using dot notation.
146
+
147
+ Args:
148
+ key_path: Dot-separated path (e.g., "API.PAGE_SIZE").
149
+ default: Fallback if key not found.
150
+
151
+ Returns:
152
+ The config value or default.
153
+ """
154
+ self._ensure_loaded()
155
+
156
+ keys = key_path.split(".")
157
+ value = self.__dict__["_config"]
158
+ for key in keys:
159
+ if isinstance(value, dict) and key in value:
160
+ value = value[key]
161
+ else:
162
+ return default
163
+ return value
164
+
165
+ def __getattr__(self, name):
166
+ self._ensure_loaded()
167
+ config = self.__dict__["_config"]
168
+ if name not in config:
169
+ raise AttributeError(f"VA7 config has no setting '{name}'")
170
+ return config[name]
171
+
172
+ def __repr__(self):
173
+ self._ensure_loaded()
174
+ keys = list(self.__dict__["_config"].keys())
175
+ return f"<VA7Config [{', '.join(keys)}]>"
va7/core/events.py ADDED
@@ -0,0 +1,118 @@
1
+ """
2
+ VA7 Event Bus — Lightweight synchronous event system for package decoupling.
3
+
4
+ Usage:
5
+ from va7.core.events import emit, listen
6
+
7
+ # Emit an event
8
+ emit("user_registered", user=user, request=request)
9
+
10
+ # Listen for an event
11
+ def on_user_registered(sender, **kwargs):
12
+ user = kwargs["user"]
13
+ send_welcome_email(user)
14
+
15
+ listen("user_registered", on_user_registered)
16
+
17
+ Design:
18
+ - Synchronous by default (consistent with Django)
19
+ - Events are strings (not classes) for simplicity
20
+ - Listeners receive (sender, **kwargs)
21
+ - emit() returns False if any listener called stop_propagation()
22
+ - No priorities, no async, no wildcards (YAGNI for now)
23
+ """
24
+
25
+ import logging
26
+ import threading
27
+ from collections import defaultdict
28
+
29
+ logger = logging.getLogger("va7.core.events")
30
+
31
+ # Thread-safe storage for listeners
32
+ _listeners: dict[str, list] = defaultdict(list)
33
+ _lock = threading.Lock()
34
+
35
+
36
+ def listen(event_name: str, listener=None):
37
+ """
38
+ Register a listener for an event.
39
+
40
+ Can be used as a decorator:
41
+ @listen("user_registered")
42
+ def on_register(sender, **kwargs):
43
+ ...
44
+
45
+ Or called directly:
46
+ listen("user_registered", my_handler)
47
+ """
48
+ if listener is not None:
49
+ # Direct registration
50
+ with _lock:
51
+ _listeners[event_name].append(listener)
52
+ return listener
53
+
54
+ # Decorator usage
55
+ def decorator(func):
56
+ with _lock:
57
+ _listeners[event_name].append(func)
58
+ return func
59
+
60
+ return decorator
61
+
62
+
63
+ def unlisten(event_name: str, listener):
64
+ """Remove a listener from an event."""
65
+ with _lock:
66
+ if event_name in _listeners:
67
+ try:
68
+ _listeners[event_name].remove(listener)
69
+ except ValueError:
70
+ pass
71
+
72
+
73
+ def emit(event_name: str, sender=None, **kwargs):
74
+ """
75
+ Emit an event to all registered listeners.
76
+
77
+ Listeners are called synchronously in registration order.
78
+ Each listener receives (sender, **kwargs).
79
+
80
+ Returns True if all listeners ran, False if propagation was stopped.
81
+ """
82
+ with _lock:
83
+ # Copy the list to avoid issues if listeners modify it
84
+ current_listeners = list(_listeners.get(event_name, []))
85
+
86
+ for listener in current_listeners:
87
+ try:
88
+ result = listener(sender, **kwargs)
89
+ if result is False:
90
+ return False
91
+ except Exception:
92
+ logger.exception(
93
+ "Event listener %s failed for event '%s'",
94
+ listener.__name__ if hasattr(listener, "__name__") else str(listener),
95
+ event_name,
96
+ )
97
+
98
+ return True
99
+
100
+
101
+ def clear(event_name: str = None):
102
+ """
103
+ Remove all listeners. Primarily for testing.
104
+
105
+ If event_name is given, clear only that event's listeners.
106
+ Otherwise, clear everything.
107
+ """
108
+ with _lock:
109
+ if event_name:
110
+ _listeners.pop(event_name, None)
111
+ else:
112
+ _listeners.clear()
113
+
114
+
115
+ def get_listeners(event_name: str) -> list:
116
+ """Return a copy of the listeners for an event (for testing/introspection)."""
117
+ with _lock:
118
+ return list(_listeners.get(event_name, []))
va7/core/exceptions.py ADDED
@@ -0,0 +1,50 @@
1
+ import logging
2
+
3
+ from rest_framework.views import exception_handler
4
+
5
+ logger = logging.getLogger("va7.core")
6
+
7
+
8
+ def custom_exception_handler(exc, context):
9
+ """
10
+ DRF exception handler with standardized error format.
11
+
12
+ Response format:
13
+ {
14
+ "success": false,
15
+ "message": "An error occurred.",
16
+ "errors": {"field": ["error messages"]}
17
+ }
18
+
19
+ Combines:
20
+ - Standardized response format (Triscaleon pattern)
21
+ - Exception logging (PGPulse pattern)
22
+ - Critical logging for 500-level errors
23
+ """
24
+ view_name = getattr(context.get("view"), "__class__", type(None)).__name__
25
+ logger.error("API Exception in %s: %s", view_name, exc, exc_info=True)
26
+
27
+ response = exception_handler(exc, context)
28
+
29
+ if response is None:
30
+ logger.critical("Unhandled exception in %s: %s", view_name, exc, exc_info=True)
31
+ return None
32
+
33
+ errors = response.data
34
+
35
+ # Normalize error format
36
+ if isinstance(errors, list):
37
+ errors = {"non_field_errors": errors}
38
+ elif isinstance(errors, dict) and "detail" in errors:
39
+ errors = {"non_field_errors": [errors["detail"]]}
40
+
41
+ response.data = {
42
+ "success": False,
43
+ "message": "An error occurred.",
44
+ "errors": errors,
45
+ }
46
+
47
+ if response.status_code >= 500:
48
+ logger.critical("Server error in %s: %s", view_name, exc, exc_info=True)
49
+
50
+ return response
va7/core/middleware.py ADDED
@@ -0,0 +1,78 @@
1
+ import logging
2
+
3
+ from django.http import JsonResponse
4
+
5
+ logger = logging.getLogger("va7.core.middleware")
6
+
7
+
8
+ class SecurityHeadersMiddleware:
9
+ """
10
+ Adds security headers to all responses.
11
+
12
+ Override CSP and other headers in your project's middleware
13
+ by subclassing or placing your middleware after VA7's.
14
+ """
15
+
16
+ def __init__(self, get_response):
17
+ self.get_response = get_response
18
+
19
+ def __call__(self, request):
20
+ response = self.get_response(request)
21
+ response["X-Content-Type-Options"] = "nosniff"
22
+ response["X-XSS-Protection"] = "1; mode=block"
23
+ response["Referrer-Policy"] = "strict-origin-when-cross-origin"
24
+ response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
25
+ response["Content-Security-Policy"] = (
26
+ "default-src 'self'; "
27
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
28
+ "style-src 'self' 'unsafe-inline'; "
29
+ "img-src 'self' data: https: blob:; "
30
+ "connect-src 'self' https: wss:; "
31
+ "frame-ancestors 'none';"
32
+ )
33
+ # Remove Server header if present
34
+ if "Server" in response:
35
+ del response["Server"]
36
+ return response
37
+
38
+
39
+ class HealthCheckMiddleware:
40
+ """
41
+ Returns 200 OK for health check endpoints before ALLOWED_HOSTS validation.
42
+
43
+ Prevents 400 errors from load balancers and deployment platforms
44
+ (Render, Railway, Fly.io, etc.) that ping /health/ before the app is ready.
45
+
46
+ Configure the health check path via VA7["CORE"]["HEALTH_CHECK_PATH"]
47
+ or default to both /health/ and /api/health/.
48
+ """
49
+
50
+ def __init__(self, get_response):
51
+ self.get_response = get_response
52
+
53
+ def __call__(self, request):
54
+ if request.path in ("/health/", "/api/health/"):
55
+ return JsonResponse({"status": "ok"})
56
+ return self.get_response(request)
57
+
58
+
59
+ class TrueClientIPMiddleware:
60
+ """
61
+ Extracts real client IP from X-Forwarded-For / X-Real-IP headers.
62
+
63
+ For more advanced IP detection (e.g., checking trusted proxies),
64
+ install django-ipware and use it directly.
65
+ """
66
+
67
+ def __init__(self, get_response):
68
+ self.get_response = get_response
69
+
70
+ def __call__(self, request):
71
+ x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
72
+ if x_forwarded_for:
73
+ request.META["REMOTE_ADDR"] = x_forwarded_for.split(",")[0].strip()
74
+ else:
75
+ x_real_ip = request.META.get("HTTP_X_REAL_IP")
76
+ if x_real_ip:
77
+ request.META["REMOTE_ADDR"] = x_real_ip
78
+ return self.get_response(request)
va7/core/mixins.py ADDED
@@ -0,0 +1,103 @@
1
+ """
2
+ VA7 Model Mixins — Optional reusable model behaviors.
3
+
4
+ These mixins complement BaseModel by adding optional fields and admin support.
5
+ Projects opt-in by using the mixin, not by configuring BaseModel.
6
+ """
7
+
8
+ from django.conf import settings
9
+ from django.contrib import admin
10
+ from django.db import models
11
+
12
+
13
+ class ChangeTrackingMixin(models.Model):
14
+ """
15
+ Optional mixin to track who created and last modified a record.
16
+
17
+ Usage:
18
+ class Article(BaseModel, ChangeTrackingMixin):
19
+ title = models.CharField(max_length=255)
20
+
21
+ def save(self, *args, **kwargs):
22
+ user = kwargs.pop("user", None)
23
+ if user:
24
+ if not self.pk:
25
+ self.created_by = user
26
+ self.updated_by = user
27
+ super().save(*args, **kwargs)
28
+
29
+ Or set fields directly:
30
+ article.created_by = user
31
+ article.updated_by = user
32
+ article.save()
33
+
34
+ This mixin provides the fields only. Population is the project's responsibility.
35
+ """
36
+
37
+ created_by = models.ForeignKey(
38
+ settings.AUTH_USER_MODEL,
39
+ on_delete=models.SET_NULL,
40
+ null=True,
41
+ blank=True,
42
+ related_name="%(class)s_created",
43
+ )
44
+ updated_by = models.ForeignKey(
45
+ settings.AUTH_USER_MODEL,
46
+ on_delete=models.SET_NULL,
47
+ null=True,
48
+ blank=True,
49
+ related_name="%(class)s_updated",
50
+ )
51
+
52
+ class Meta:
53
+ abstract = True
54
+
55
+
56
+ class SoftDeleteAdminMixin(admin.ModelAdmin):
57
+ """
58
+ Admin mixin that shows soft-deleted objects and provides restore actions.
59
+
60
+ Usage:
61
+ @admin.register(Article)
62
+ class ArticleAdmin(SoftDeleteAdminMixin, admin.ModelAdmin):
63
+ list_display = ["title", "is_deleted"]
64
+
65
+ Features:
66
+ - Shows is_deleted in list display
67
+ - Filter by deletion status
68
+ - Restore action for soft-deleted objects
69
+ """
70
+
71
+ def get_queryset(self, request):
72
+ """Use all_with_deleted to show soft-deleted objects in admin."""
73
+ qs = super().get_queryset(request)
74
+ if hasattr(qs, "all_with_deleted"):
75
+ return qs.all_with_deleted.all()
76
+ return qs
77
+
78
+ def get_list_filter(self, request):
79
+ """Add is_deleted to list filters if not already present."""
80
+ filters = list(super().get_list_filter(request))
81
+ if "is_deleted" not in filters:
82
+ filters.append("is_deleted")
83
+ return filters
84
+
85
+ @admin.action(description="Restore selected items")
86
+ def restore_selected(self, request, queryset):
87
+ """Restore soft-deleted objects."""
88
+ count = 0
89
+ for obj in queryset.filter(is_deleted=True):
90
+ obj.restore()
91
+ count += 1
92
+ self.message_user(request, f"Restored {count} item(s).")
93
+
94
+ def get_actions(self, request):
95
+ """Add restore action for admin."""
96
+ actions = super().get_actions(request)
97
+ if "restore_selected" not in actions:
98
+ actions["restore_selected"] = (
99
+ self.restore_selected,
100
+ "restore_selected",
101
+ "Restore selected items",
102
+ )
103
+ return actions
va7/core/models.py ADDED
@@ -0,0 +1,70 @@
1
+ import uuid
2
+
3
+ from django.db import models
4
+
5
+
6
+ class SoftDeleteManager(models.Manager):
7
+ """Manager that excludes soft-deleted objects by default."""
8
+
9
+ def get_queryset(self):
10
+ return super().get_queryset().filter(is_deleted=False)
11
+
12
+
13
+ class BaseModel(models.Model):
14
+ """
15
+ Abstract base model for all VA7 models.
16
+
17
+ Provides:
18
+ - UUID primary key
19
+ - created_at / updated_at timestamps
20
+ - Soft delete (is_deleted flag + SoftDeleteManager)
21
+
22
+ Usage:
23
+ class MyModel(BaseModel):
24
+ name = models.CharField(max_length=255)
25
+
26
+ # Default queryset excludes soft-deleted
27
+ MyModel.objects.all()
28
+
29
+ # Include soft-deleted
30
+ MyModel.all_with_deleted.all()
31
+
32
+ # Soft delete
33
+ obj.soft_delete()
34
+
35
+ # Restore
36
+ obj.restore()
37
+ """
38
+
39
+ id = models.UUIDField(
40
+ primary_key=True,
41
+ default=uuid.uuid4,
42
+ editable=False,
43
+ )
44
+ created_at = models.DateTimeField(auto_now_add=True, db_index=True)
45
+ updated_at = models.DateTimeField(auto_now=True)
46
+ is_deleted = models.BooleanField(default=False, db_index=True)
47
+
48
+ objects = SoftDeleteManager()
49
+ all_with_deleted = models.Manager()
50
+
51
+ class Meta:
52
+ abstract = True
53
+ ordering = ["-created_at"]
54
+
55
+ def soft_delete(self):
56
+ """Soft delete — marks as deleted without removing the row."""
57
+ self.is_deleted = True
58
+ self.save(update_fields=["is_deleted", "updated_at"])
59
+
60
+ def hard_delete(self):
61
+ """Permanent delete — use with caution."""
62
+ return super().delete()
63
+
64
+ def restore(self):
65
+ """Restore a soft-deleted object."""
66
+ self.is_deleted = False
67
+ self.save(update_fields=["is_deleted", "updated_at"])
68
+
69
+ def __repr__(self):
70
+ return f"<{self.__class__.__name__} {self.pk}>"
va7/core/utils.py ADDED
@@ -0,0 +1,92 @@
1
+ import logging
2
+ import os
3
+ import threading
4
+ from functools import wraps
5
+
6
+ from django.core.exceptions import ImproperlyConfigured
7
+
8
+ logger = logging.getLogger("va7.core")
9
+
10
+
11
+ def is_truthy(value: str | None) -> bool:
12
+ """Check if a string value represents a truthy boolean.
13
+
14
+ Returns True for: 'true', '1', 't', 'yes', 'on' (case-insensitive).
15
+ """
16
+ return (value or "").lower() in ("true", "1", "t", "yes", "on")
17
+
18
+
19
+ def get_env_variable(var_name: str, default=None, required_in_prod: bool = False):
20
+ """
21
+ Get an environment variable with fallback and production validation.
22
+
23
+ Args:
24
+ var_name: Environment variable name.
25
+ default: Fallback value if not set.
26
+ required_in_prod: If True, raises ImproperlyConfigured in production.
27
+
28
+ Returns:
29
+ The environment variable value or default.
30
+
31
+ Raises:
32
+ ImproperlyConfigured: If required in production and not set.
33
+ """
34
+ value = os.getenv(var_name, default)
35
+
36
+ from django.conf import settings as django_settings
37
+
38
+ is_prod = not getattr(django_settings, "DEBUG", False)
39
+
40
+ if is_prod and required_in_prod and (value is None or value == default):
41
+ raise ImproperlyConfigured(
42
+ f"VA7: Set the {var_name} environment variable in production."
43
+ )
44
+
45
+ return value
46
+
47
+
48
+ def run_in_background(func, *args, **kwargs):
49
+ """
50
+ Fire-and-forget background task using threading.
51
+
52
+ Ensures DB connections are cleaned up after the thread completes.
53
+ Use for lightweight tasks (emails, notifications).
54
+ For heavy workloads, use Celery (va7-tasks).
55
+
56
+ Returns:
57
+ The started Thread instance.
58
+ """
59
+
60
+ def _target():
61
+ try:
62
+ func(*args, **kwargs)
63
+ except Exception:
64
+ logger.exception("Background task failed: %s", func.__name__)
65
+ finally:
66
+ from django import db
67
+
68
+ db.connections.close_all()
69
+
70
+ thread = threading.Thread(target=_target, daemon=True)
71
+ thread.start()
72
+ return thread
73
+
74
+
75
+ def deprecated(message: str):
76
+ """Decorator to mark functions as deprecated with a migration message."""
77
+
78
+ def decorator(func):
79
+ @wraps(func)
80
+ def wrapper(*args, **kwargs):
81
+ import warnings
82
+
83
+ warnings.warn(
84
+ f"{func.__name__} is deprecated. {message}",
85
+ DeprecationWarning,
86
+ stacklevel=2,
87
+ )
88
+ return func(*args, **kwargs)
89
+
90
+ return wrapper
91
+
92
+ return decorator
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: va7-core
3
+ Version: 0.1.0
4
+ Summary: VA7 Framework — Foundation layer for Django SaaS applications
5
+ Project-URL: Homepage, https://github.com/Vishal-2209/va7
6
+ Project-URL: Repository, https://github.com/Vishal-2209/va7
7
+ Author: Vishal Aidasani
8
+ License-Expression: MIT
9
+ Keywords: base-model,django,framework,middleware,saas
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Django
12
+ Classifier: Framework :: Django :: 5.1
13
+ Classifier: Framework :: Django :: 6.0
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: dj-database-url>=2.0
21
+ Requires-Dist: django<7.0,>=5.1
22
+ Requires-Dist: djangorestframework>=3.15
23
+ Requires-Dist: python-dotenv>=1.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-django>=4.8; extra == 'dev'
26
+ Requires-Dist: pytest>=8.0; extra == 'dev'
27
+ Requires-Dist: ruff>=0.5; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # VA7 Core
31
+
32
+ Foundation layer for Django SaaS applications.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install va7-core
38
+ ```
39
+
40
+ ## Quick Start
41
+
42
+ ```python
43
+ # settings.py
44
+ INSTALLED_APPS = [
45
+ ...
46
+ "va7.core",
47
+ ]
48
+
49
+ MIDDLEWARE = [
50
+ "va7.core.middleware.SecurityHeadersMiddleware",
51
+ "va7.core.middleware.HealthCheckMiddleware",
52
+ # "va7.core.middleware.TrueClientIPMiddleware", # Optional
53
+ ]
54
+
55
+ REST_FRAMEWORK = {
56
+ "EXCEPTION_HANDLER": "va7.core.exceptions.custom_exception_handler",
57
+ }
58
+ ```
59
+
60
+ ## What's Included
61
+
62
+ - **BaseModel** — UUID PKs, timestamps, soft-delete
63
+ - **Event Bus** — `emit()`, `listen()` for cross-package decoupling
64
+ - **Exception Handler** — Standardized DRF error responses
65
+ - **Middleware** — Security headers, health check, client IP
66
+ - **Config** — Django-style lazy settings with deep merge
67
+ - **Mixins** — Change tracking, soft-delete admin
68
+
69
+ ## Project Structure
70
+
71
+ ```
72
+ va7-core/
73
+ ├── pyproject.toml
74
+ ├── va7/
75
+ │ ├── core/
76
+ │ │ ├── __init__.py # Public API (lazy imports)
77
+ │ │ ├── models.py # BaseModel, SoftDeleteManager
78
+ │ │ ├── events.py # Event bus (emit, listen, unlisten)
79
+ │ │ ├── exceptions.py # custom_exception_handler
80
+ │ │ ├── middleware.py # SecurityHeaders, HealthCheck, ClientIP
81
+ │ │ ├── mixins.py # ChangeTrackingMixin, SoftDeleteAdminMixin
82
+ │ │ ├── utils.py # is_truthy, get_env_variable, run_in_background
83
+ │ │ └── config.py # LazySettings, VA7_DEFAULTS
84
+ │ └── conf/
85
+ │ ├── __init__.py # settings = LazySettings()
86
+ │ └── global_settings.py # Documented defaults
87
+ └── tests/
88
+ ├── test_config.py
89
+ ├── test_events.py
90
+ ├── test_exceptions.py
91
+ ├── test_middleware.py
92
+ ├── test_mixins.py
93
+ └── test_models.py
94
+ ```
95
+
96
+ ## Configuration
97
+
98
+ All configuration is optional with sensible defaults. Override in `settings.py`:
99
+
100
+ ```python
101
+ VA7 = {
102
+ "PROJECT_NAME": "My SaaS",
103
+ "IDENTITY": {
104
+ "ROLES": {
105
+ "ADMIN": {"label": "Admin", "is_admin": True},
106
+ "MEMBER": {"label": "Member", "is_admin": False},
107
+ },
108
+ },
109
+ }
110
+ ```
111
+
112
+ Access configuration:
113
+
114
+ ```python
115
+ from va7.conf import settings
116
+
117
+ # Attribute access
118
+ page_size = settings.API["PAGE_SIZE"]
119
+
120
+ # Dot notation
121
+ page_size = settings.get("API.PAGE_SIZE", 20)
122
+ ```
123
+
124
+ ## Testing
125
+
126
+ ```bash
127
+ pip install -e "packages/va7-core[dev]"
128
+ pytest tests/ -v
129
+ ```
@@ -0,0 +1,14 @@
1
+ va7/conf/__init__.py,sha256=_GpFv0snhf8oLDR3aJJEqpiQDNsKDQhDTHlz2muki7s,92
2
+ va7/conf/global_settings.py,sha256=f12tOggAzBfza3nQRKqPlzA4fBonw7tFNQ_k_4hpxaw,1688
3
+ va7/core/__init__.py,sha256=Wip3IigDK6swBr5fEfG1iRgSiyA4v6NhJ-L6sQ0R5yc,1364
4
+ va7/core/apps.py,sha256=tpuq_i8tY0m9MGjIn2mWzSooPbaoLTKBZOkqKzSnlEM,200
5
+ va7/core/config.py,sha256=qGYfrOHeNmwoXGbl14BbRCTfOOnFAVN0nzuIgDDJLXI,5046
6
+ va7/core/events.py,sha256=mFA-fmgHI4crOwQfgr2Grr5f7L48gWIo2pNoq39uX5A,3185
7
+ va7/core/exceptions.py,sha256=42hkDOJ7LELA-clo23OXa5fNGHQngVbWz7RiiTkk7AY,1403
8
+ va7/core/middleware.py,sha256=hBILkEgSov19qEBUlDNUBKktmQ29VgEUwFlwY3POoAA,2608
9
+ va7/core/mixins.py,sha256=gVtjm5fL9ZkM1isEbLZXtAHIgD04nlDjfXq3dCwSRf4,3168
10
+ va7/core/models.py,sha256=-j2bfetr8js86XJ8-L7CJhx5PetnruNbX2TtTKZncZ8,1815
11
+ va7/core/utils.py,sha256=clR7AkIcTYV3UWnIhRltOhZyKHJuekdbmRRPd3yLkwo,2471
12
+ va7_core-0.1.0.dist-info/METADATA,sha256=B6kX3pDncmYkgPy9GMJXNKqGzezQtmZoaFswpk4kbHg,3629
13
+ va7_core-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
14
+ va7_core-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any