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 +5 -0
- va7/conf/global_settings.py +72 -0
- va7/core/__init__.py +48 -0
- va7/core/apps.py +8 -0
- va7/core/config.py +175 -0
- va7/core/events.py +118 -0
- va7/core/exceptions.py +50 -0
- va7/core/middleware.py +78 -0
- va7/core/mixins.py +103 -0
- va7/core/models.py +70 -0
- va7/core/utils.py +92 -0
- va7_core-0.1.0.dist-info/METADATA +129 -0
- va7_core-0.1.0.dist-info/RECORD +14 -0
- va7_core-0.1.0.dist-info/WHEEL +4 -0
va7/conf/__init__.py
ADDED
|
@@ -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
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,,
|