ntc-dev-kit 1.0.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.
- ntc/__init__.py +5 -0
- ntc/app_creator.py +296 -0
- ntc/cli.py +4 -0
- ntc/cli_enhanced.py +820 -0
- ntc/composer/__init__.py +0 -0
- ntc/composer/main.py +443 -0
- ntc/deployment/__init__.py +0 -0
- ntc/templates/__init__.py +2 -0
- ntc/templates/auth_app/__init__.py +0 -0
- ntc/templates/auth_app/admin.py +24 -0
- ntc/templates/auth_app/apps.py +6 -0
- ntc/templates/auth_app/models.py +30 -0
- ntc/templates/auth_app/serializers.py +101 -0
- ntc/templates/auth_app/urls.py +19 -0
- ntc/templates/auth_app/views.py +175 -0
- ntc/templates/drf_structure/__init__.py +1 -0
- ntc/templates/drf_structure/drf_structure.md +83 -0
- ntc/templates/drf_structure/main.py +389 -0
- ntc/templates/drf_structure/structure.json +126 -0
- ntc/templates/drf_structure/templates/.entry/start_core_backend.sh +37 -0
- ntc/templates/drf_structure/templates/.entry/start_tasks_backend.sh +30 -0
- ntc/templates/drf_structure/templates/CLAUDE.md +194 -0
- ntc/templates/drf_structure/templates/README.md +117 -0
- ntc/templates/drf_structure/templates/apps/app_admin.py +3 -0
- ntc/templates/drf_structure/templates/apps/app_apps.py +6 -0
- ntc/templates/drf_structure/templates/apps/app_models.py +4 -0
- ntc/templates/drf_structure/templates/apps/app_urls.py +8 -0
- ntc/templates/drf_structure/templates/apps/app_views.py +6 -0
- ntc/templates/drf_structure/templates/apps/common_apps.py +6 -0
- ntc/templates/drf_structure/templates/apps/common_models.py +8 -0
- ntc/templates/drf_structure/templates/config/gunicorn_config.py +9 -0
- ntc/templates/drf_structure/templates/config/http.conf +33 -0
- ntc/templates/drf_structure/templates/core/base_model.py +9 -0
- ntc/templates/drf_structure/templates/core/formatter.py +5 -0
- ntc/templates/drf_structure/templates/core/logger.py +8 -0
- ntc/templates/drf_structure/templates/core/pagination.py +6 -0
- ntc/templates/drf_structure/templates/core/response_wrapper.py +7 -0
- ntc/templates/drf_structure/templates/core/services/azure/email/backend.py +108 -0
- ntc/templates/drf_structure/templates/core/services/azure/email/client.py +296 -0
- ntc/templates/drf_structure/templates/core/services/azure/email/notifier.py +102 -0
- ntc/templates/drf_structure/templates/core/services/object_storage.py +285 -0
- ntc/templates/drf_structure/templates/django_project/asgi.py +5 -0
- ntc/templates/drf_structure/templates/django_project/celery.py +8 -0
- ntc/templates/drf_structure/templates/django_project/dev_settings.py +2 -0
- ntc/templates/drf_structure/templates/django_project/django_settings.txt +322 -0
- ntc/templates/drf_structure/templates/django_project/management_command.py +114 -0
- ntc/templates/drf_structure/templates/django_project/prod_settings.py +3 -0
- ntc/templates/drf_structure/templates/django_project/schema.py +7 -0
- ntc/templates/drf_structure/templates/django_project/urls.py +6 -0
- ntc/templates/drf_structure/templates/django_project/wsgi.py +5 -0
- ntc/templates/drf_structure/templates/docker/docker-compose.yml +11 -0
- ntc/templates/drf_structure/templates/knowledge/knowledge_description.md +201 -0
- ntc/templates/drf_structure/templates/knowledge/knowledge_design_pattern.md +722 -0
- ntc/templates/drf_structure/templates/knowledge/knowledge_structure.md +151 -0
- ntc/templates/drf_structure/templates/root/README.md +307 -0
- ntc/templates/drf_structure/templates/root/django_env.txt +98 -0
- ntc/templates/drf_structure/templates/root/django_requirements.txt +24 -0
- ntc/templates/drf_structure/templates/root/gitignore.txt +6 -0
- ntc/templates/drf_structure/templates/root/manage.py +11 -0
- ntc/templates/flutter/.gitignore +44 -0
- ntc/templates/flutter/.knowledge/knowledge_description.md +215 -0
- ntc/templates/flutter/.knowledge/knowledge_design_pattern.md +310 -0
- ntc/templates/flutter/.knowledge/knowledge_structure.md +83 -0
- ntc/templates/flutter/CLAUDE.md +190 -0
- ntc/templates/flutter/__init__.py +0 -0
- ntc/templates/flutter/analysis_options.yaml +9 -0
- ntc/templates/flutter/main.py +187 -0
- ntc/templates/flutter/pubspec.yaml +65 -0
- ntc/templates/flutter/structure.json +51 -0
- ntc/templates/nextjs/.env.example +10 -0
- ntc/templates/nextjs/.gitignore +36 -0
- ntc/templates/nextjs/CLAUDE.md +181 -0
- ntc/templates/nextjs/README.md +94 -0
- ntc/templates/nextjs/__init__.py +0 -0
- ntc/templates/nextjs/knowledge/knowledge_description.md +208 -0
- ntc/templates/nextjs/knowledge/knowledge_design_pattern.md +460 -0
- ntc/templates/nextjs/knowledge/knowledge_structure.md +75 -0
- ntc/templates/nextjs/main.py +224 -0
- ntc/templates/nextjs/package.json +25 -0
- ntc/templates/nextjs/src/app/globals.css +26 -0
- ntc/templates/nextjs/src/app/layout.tsx +59 -0
- ntc/templates/nextjs/src/app/page.tsx +21 -0
- ntc/templates/nextjs/src/components/Header.tsx +16 -0
- ntc/templates/nextjs/src/services/api.ts +81 -0
- ntc/templates/nextjs/src/utils/constants.ts +1 -0
- ntc/templates/nextjs/structure.json +32 -0
- ntc/templates/nextjs/tsconfig.json +27 -0
- ntc/templates/react_admin/.env.example +1 -0
- ntc/templates/react_admin/.gitignore +10 -0
- ntc/templates/react_admin/CLAUDE.md +210 -0
- ntc/templates/react_admin/README.md +72 -0
- ntc/templates/react_admin/__init__.py +0 -0
- ntc/templates/react_admin/index.html +13 -0
- ntc/templates/react_admin/knowledge/knowledge_description.md +202 -0
- ntc/templates/react_admin/knowledge/knowledge_design_pattern.md +448 -0
- ntc/templates/react_admin/knowledge/knowledge_structure.md +97 -0
- ntc/templates/react_admin/main.py +208 -0
- ntc/templates/react_admin/package.json +35 -0
- ntc/templates/react_admin/src/App.tsx +60 -0
- ntc/templates/react_admin/src/components/ProtectedRoute.tsx +16 -0
- ntc/templates/react_admin/src/components/Sidebar.tsx +90 -0
- ntc/templates/react_admin/src/components/TopNav.tsx +44 -0
- ntc/templates/react_admin/src/context/AuthContext.tsx +107 -0
- ntc/templates/react_admin/src/context/ThemeContext.tsx +34 -0
- ntc/templates/react_admin/src/context/WorkspaceContext.tsx +28 -0
- ntc/templates/react_admin/src/hooks/useApi.ts +39 -0
- ntc/templates/react_admin/src/index.css +34 -0
- ntc/templates/react_admin/src/layouts/AuthLayout.tsx +23 -0
- ntc/templates/react_admin/src/layouts/DashboardLayout.tsx +24 -0
- ntc/templates/react_admin/src/main.tsx +5 -0
- ntc/templates/react_admin/src/pages/DashboardPage.tsx +10 -0
- ntc/templates/react_admin/src/pages/auth/LoginPage.tsx +79 -0
- ntc/templates/react_admin/src/pages/auth/RegisterPage.tsx +93 -0
- ntc/templates/react_admin/src/services/api.service.ts +44 -0
- ntc/templates/react_admin/src/services/base.service.ts +193 -0
- ntc/templates/react_admin/structure.json +52 -0
- ntc/templates/react_admin/tsconfig.app.json +25 -0
- ntc/templates/react_admin/tsconfig.json +7 -0
- ntc/templates/react_admin/tsconfig.node.json +19 -0
- ntc/templates/react_admin/vite.config.ts +13 -0
- ntc/templates/react_structure/CLAUDE.md +176 -0
- ntc/templates/react_structure/__init__.py +1 -0
- ntc/templates/react_structure/index.css +21 -0
- ntc/templates/react_structure/index.html +13 -0
- ntc/templates/react_structure/knowledge/knowledge_description.md +170 -0
- ntc/templates/react_structure/knowledge/knowledge_design_pattern.md +411 -0
- ntc/templates/react_structure/knowledge/knowledge_structure.md +74 -0
- ntc/templates/react_structure/main.py +217 -0
- ntc/templates/react_structure/package.json +32 -0
- ntc/templates/react_structure/src/App.tsx +63 -0
- ntc/templates/react_structure/src/components/Header.tsx +51 -0
- ntc/templates/react_structure/src/main.tsx +5 -0
- ntc/templates/react_structure/src/services/api.ts +90 -0
- ntc/templates/react_structure/src/utils/constants.ts +1 -0
- ntc/templates/react_structure/structure.json +65 -0
- ntc/templates/react_structure/tsconfig.app.json +25 -0
- ntc/templates/react_structure/tsconfig.json +7 -0
- ntc/templates/react_structure/tsconfig.node.json +19 -0
- ntc/templates/react_structure/vite.config.ts +12 -0
- ntc/utils/__init__.py +1 -0
- ntc/utils/environment.py +67 -0
- ntc/utils/output.py +41 -0
- ntc_dev_kit-1.0.0.dist-info/METADATA +188 -0
- ntc_dev_kit-1.0.0.dist-info/RECORD +148 -0
- ntc_dev_kit-1.0.0.dist-info/WHEEL +5 -0
- ntc_dev_kit-1.0.0.dist-info/entry_points.txt +2 -0
- ntc_dev_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
- ntc_dev_kit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
import re
|
|
3
|
+
import uuid
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
import posixpath
|
|
7
|
+
|
|
8
|
+
import boto3
|
|
9
|
+
from botocore.config import Config as BotoConfig
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
IMAGE_MIME_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}
|
|
14
|
+
AVATAR_MIME_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
|
15
|
+
VIDEO_MIME_TYPES = {"video/mp4", "video/quicktime", "video/webm"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
MIME_TO_EXTENSION = {
|
|
19
|
+
"image/png": "png",
|
|
20
|
+
"image/jpeg": "jpg",
|
|
21
|
+
"image/webp": "webp",
|
|
22
|
+
"image/gif": "gif",
|
|
23
|
+
"video/mp4": "mp4",
|
|
24
|
+
"video/quicktime": "mov",
|
|
25
|
+
"video/webm": "webm",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sanitize_filename(filename: str) -> str:
|
|
30
|
+
name = Path(filename).name.strip().lower()
|
|
31
|
+
stem = Path(name).stem
|
|
32
|
+
ext = Path(name).suffix.lower()
|
|
33
|
+
stem = re.sub(r"[^a-z0-9]+", "-", stem).strip("-")
|
|
34
|
+
if not stem:
|
|
35
|
+
stem = "file"
|
|
36
|
+
return f"{stem}{ext}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extension_for_upload(uploaded_file) -> str:
|
|
40
|
+
content_type = (getattr(uploaded_file, "content_type", "") or "").lower()
|
|
41
|
+
if content_type in MIME_TO_EXTENSION:
|
|
42
|
+
return MIME_TO_EXTENSION[content_type]
|
|
43
|
+
|
|
44
|
+
suffix = Path(uploaded_file.name).suffix.lower().lstrip(".")
|
|
45
|
+
guessed = mimetypes.guess_type(uploaded_file.name)[0]
|
|
46
|
+
if guessed in MIME_TO_EXTENSION and suffix:
|
|
47
|
+
return suffix
|
|
48
|
+
return suffix or "bin"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_uploaded_file(uploaded_file, allowed_mime_types, max_size_bytes: int):
|
|
52
|
+
content_type = (getattr(uploaded_file, "content_type", "") or "").lower()
|
|
53
|
+
if content_type not in allowed_mime_types:
|
|
54
|
+
return f"Unsupported file type: {content_type or 'unknown'}"
|
|
55
|
+
|
|
56
|
+
if uploaded_file.size > max_size_bytes:
|
|
57
|
+
return f"File size exceeds {max_size_bytes // (1024 * 1024)} MB limit"
|
|
58
|
+
|
|
59
|
+
expected_ext = MIME_TO_EXTENSION.get(content_type)
|
|
60
|
+
actual_ext = Path(uploaded_file.name).suffix.lower().lstrip(".")
|
|
61
|
+
if expected_ext and actual_ext and expected_ext != actual_ext:
|
|
62
|
+
if not (expected_ext == "jpg" and actual_ext == "jpeg"):
|
|
63
|
+
return "File extension does not match MIME type"
|
|
64
|
+
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def image_dimensions_or_none(uploaded_file):
|
|
69
|
+
try:
|
|
70
|
+
from PIL import Image
|
|
71
|
+
|
|
72
|
+
current_position = uploaded_file.tell()
|
|
73
|
+
uploaded_file.seek(0)
|
|
74
|
+
with Image.open(uploaded_file) as image:
|
|
75
|
+
width, height = image.size
|
|
76
|
+
uploaded_file.seek(current_position)
|
|
77
|
+
return width, height
|
|
78
|
+
except Exception:
|
|
79
|
+
return None, None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _build_s3_client(endpoint: str, access_key: str, secret_key: str, region: str):
|
|
83
|
+
"""Return a boto3 S3 client configured for an S3-compatible endpoint.
|
|
84
|
+
|
|
85
|
+
Cloudflare R2 requires SigV4 and the ``auto`` region. ``virtual``
|
|
86
|
+
addressing style is not universally supported by R2 custom domains,
|
|
87
|
+
so we stick with the default path-style-friendly config.
|
|
88
|
+
"""
|
|
89
|
+
return boto3.client(
|
|
90
|
+
's3',
|
|
91
|
+
endpoint_url=endpoint,
|
|
92
|
+
aws_access_key_id=access_key,
|
|
93
|
+
aws_secret_access_key=secret_key,
|
|
94
|
+
region_name=region or 'auto',
|
|
95
|
+
config=BotoConfig(
|
|
96
|
+
signature_version='s3v4',
|
|
97
|
+
retries={'max_attempts': 3},
|
|
98
|
+
s3={'addressing_style': 'path'},
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ObjectStorageService:
|
|
104
|
+
"""High-level helper on top of Cloudflare R2 (S3-compatible).
|
|
105
|
+
|
|
106
|
+
All application code that needs to upload / delete / resolve media
|
|
107
|
+
should go through this service. Construction fails loudly when R2
|
|
108
|
+
credentials are missing — we intentionally do NOT fall back silently
|
|
109
|
+
so misconfiguration is obvious in dev.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(self):
|
|
113
|
+
endpoint = getattr(settings, 'R2_ENDPOINT', '')
|
|
114
|
+
access_key = getattr(settings, 'R2_ACCESS_KEY_ID', '')
|
|
115
|
+
secret_key = getattr(settings, 'R2_SECRET_ACCESS_KEY', '')
|
|
116
|
+
bucket = getattr(settings, 'R2_BUCKET', '')
|
|
117
|
+
public_base_url = getattr(settings, 'R2_PUBLIC_BASE_URL', '').strip()
|
|
118
|
+
|
|
119
|
+
if not (endpoint and access_key and secret_key and bucket):
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
'Cloudflare R2 is not configured. Set R2_ACCOUNT_ID (or '
|
|
122
|
+
'R2_ENDPOINT), R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, '
|
|
123
|
+
'and R2_BUCKET in the environment.'
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if not public_base_url:
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
'R2_PUBLIC_BASE_URL is not configured. Set it to your bucket\'s '
|
|
129
|
+
'R2.dev subdomain or custom domain (e.g., '
|
|
130
|
+
'https://pub-<account-id>.r2.dev).'
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
self.client = _build_s3_client(
|
|
134
|
+
endpoint=endpoint,
|
|
135
|
+
access_key=access_key,
|
|
136
|
+
secret_key=secret_key,
|
|
137
|
+
region=getattr(settings, 'R2_REGION', 'auto'),
|
|
138
|
+
)
|
|
139
|
+
self.bucket = bucket
|
|
140
|
+
self.media_path = (getattr(settings, 'R2_MEDIA_PATH', '') or '').strip('/ ')
|
|
141
|
+
self.media_base_url = public_base_url.rstrip('/')
|
|
142
|
+
|
|
143
|
+
def _object_key(self, key: str) -> str:
|
|
144
|
+
return posix_safe_join(self.media_path, key)
|
|
145
|
+
|
|
146
|
+
def _public_url(self, key: str) -> str:
|
|
147
|
+
path = self._object_key(key)
|
|
148
|
+
return f"{self.media_base_url}/{path}"
|
|
149
|
+
|
|
150
|
+
def upload_file(self, uploaded_file, key: str):
|
|
151
|
+
content_type = getattr(uploaded_file, "content_type", None)
|
|
152
|
+
uploaded_file.seek(0)
|
|
153
|
+
body = uploaded_file.read()
|
|
154
|
+
|
|
155
|
+
extra_args = {}
|
|
156
|
+
if content_type:
|
|
157
|
+
extra_args['ContentType'] = content_type
|
|
158
|
+
|
|
159
|
+
self.client.put_object(
|
|
160
|
+
Bucket=self.bucket,
|
|
161
|
+
Key=self._object_key(key),
|
|
162
|
+
Body=body,
|
|
163
|
+
**extra_args,
|
|
164
|
+
)
|
|
165
|
+
return self._public_url(key), key
|
|
166
|
+
|
|
167
|
+
def upload_workspace_logo(self, workspace_id: str, uploaded_file):
|
|
168
|
+
ext = extension_for_upload(uploaded_file)
|
|
169
|
+
key = f"workspaces/{workspace_id}/logo.{ext}"
|
|
170
|
+
return self.upload_file(uploaded_file, key)
|
|
171
|
+
|
|
172
|
+
def upload_user_avatar(self, user_id: str, uploaded_file):
|
|
173
|
+
ext = extension_for_upload(uploaded_file)
|
|
174
|
+
key = f"users/{user_id}/avatar.{ext}"
|
|
175
|
+
return self.upload_file(uploaded_file, key)
|
|
176
|
+
|
|
177
|
+
def upload_workspace_media(self, workspace_id: str, uploaded_file):
|
|
178
|
+
safe_name = sanitize_filename(uploaded_file.name)
|
|
179
|
+
key = f"workspaces/{workspace_id}/media/{uuid.uuid4()}-{safe_name}"
|
|
180
|
+
return self.upload_file(uploaded_file, key)
|
|
181
|
+
|
|
182
|
+
def delete_by_key(self, key: str):
|
|
183
|
+
self.client.delete_object(
|
|
184
|
+
Bucket=self.bucket,
|
|
185
|
+
Key=self._object_key(key),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def upload_image_from_url(self, image_url: str, key: str) -> tuple:
|
|
189
|
+
"""Download an image from URL and upload to R2 storage.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
image_url: The URL to download the image from
|
|
193
|
+
key: The storage key (path) to save the image
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
tuple: (public_url, storage_key) or (None, None) if failed
|
|
197
|
+
"""
|
|
198
|
+
if not image_url:
|
|
199
|
+
return None, None
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
import requests
|
|
203
|
+
from io import BytesIO
|
|
204
|
+
|
|
205
|
+
# Download the image
|
|
206
|
+
resp = requests.get(image_url, timeout=30)
|
|
207
|
+
resp.raise_for_status()
|
|
208
|
+
|
|
209
|
+
# Determine content type from response
|
|
210
|
+
content_type = resp.headers.get('content-type', 'image/jpeg')
|
|
211
|
+
if not content_type.startswith('image/'):
|
|
212
|
+
content_type = 'image/jpeg'
|
|
213
|
+
|
|
214
|
+
# Extract extension from content-type or URL
|
|
215
|
+
ext = 'jpg'
|
|
216
|
+
if 'png' in content_type:
|
|
217
|
+
ext = 'png'
|
|
218
|
+
elif 'gif' in content_type:
|
|
219
|
+
ext = 'gif'
|
|
220
|
+
elif 'webp' in content_type:
|
|
221
|
+
ext = 'webp'
|
|
222
|
+
elif 'jpg' in image_url.lower() or 'jpeg' in image_url.lower():
|
|
223
|
+
ext = 'jpg'
|
|
224
|
+
elif 'png' in image_url.lower():
|
|
225
|
+
ext = 'png'
|
|
226
|
+
|
|
227
|
+
# Ensure key has extension
|
|
228
|
+
if not key.endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')):
|
|
229
|
+
key = f"{key}.{ext}"
|
|
230
|
+
|
|
231
|
+
# Upload to R2
|
|
232
|
+
body = resp.content
|
|
233
|
+
self.client.put_object(
|
|
234
|
+
Bucket=self.bucket,
|
|
235
|
+
Key=self._object_key(key),
|
|
236
|
+
Body=body,
|
|
237
|
+
ContentType=content_type,
|
|
238
|
+
)
|
|
239
|
+
return self._public_url(key), key
|
|
240
|
+
|
|
241
|
+
except Exception as e:
|
|
242
|
+
import logging
|
|
243
|
+
logger = logging.getLogger(__name__)
|
|
244
|
+
logger.warning(f"Failed to download/upload image from {image_url}: {e}")
|
|
245
|
+
return None, None
|
|
246
|
+
|
|
247
|
+
def key_from_url(self, url: str):
|
|
248
|
+
"""Extract the storage key from a public URL.
|
|
249
|
+
|
|
250
|
+
Handles three cases so both freshly-uploaded R2 URLs and legacy
|
|
251
|
+
rows still written against the MinIO base are resolvable:
|
|
252
|
+
|
|
253
|
+
1. URL starts with the configured R2 public base
|
|
254
|
+
2. URL is a bare S3/R2 endpoint URL (``…/<bucket>/<key>``)
|
|
255
|
+
3. URL is just a path, optionally prefixed by ``R2_MEDIA_PATH``
|
|
256
|
+
"""
|
|
257
|
+
if not url:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
if self.media_base_url and url.startswith(f"{self.media_base_url}/"):
|
|
261
|
+
tail = url.replace(f"{self.media_base_url}/", "", 1)
|
|
262
|
+
return _strip_prefix(tail, self.media_path)
|
|
263
|
+
|
|
264
|
+
parsed = urlparse(url)
|
|
265
|
+
path = parsed.path.lstrip("/")
|
|
266
|
+
bucket_prefix = f"{self.bucket}/"
|
|
267
|
+
if path.startswith(bucket_prefix):
|
|
268
|
+
path = path[len(bucket_prefix):]
|
|
269
|
+
|
|
270
|
+
return _strip_prefix(path, self.media_path) or None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _strip_prefix(path: str, prefix: str) -> str:
|
|
274
|
+
prefix = (prefix or '').strip('/ ')
|
|
275
|
+
if prefix and path.startswith(f"{prefix}/"):
|
|
276
|
+
return path[len(prefix) + 1:]
|
|
277
|
+
return path
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def posix_safe_join(base: str, name: str) -> str:
|
|
281
|
+
base = (base or "").strip("/ ")
|
|
282
|
+
name = (name or "").replace("\\", "/").lstrip("/")
|
|
283
|
+
if base:
|
|
284
|
+
return f"{base}/{name}"
|
|
285
|
+
return name
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from __future__ import absolute_import, unicode_literals
|
|
2
|
+
import os
|
|
3
|
+
from celery import Celery
|
|
4
|
+
|
|
5
|
+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', '{project_name}.settings.dev')
|
|
6
|
+
app = Celery('{project_name}')
|
|
7
|
+
app.config_from_object('django.conf:settings', namespace='CELERY')
|
|
8
|
+
app.autodiscover_tasks()
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import environ
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
import pytz
|
|
5
|
+
import os
|
|
6
|
+
import sentry_sdk
|
|
7
|
+
from sentry_sdk.integrations.logging import LoggingIntegration
|
|
8
|
+
from sentry_sdk.integrations.django import DjangoIntegration
|
|
9
|
+
|
|
10
|
+
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
11
|
+
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
|
12
|
+
|
|
13
|
+
# Environment Variables
|
|
14
|
+
env = environ.Env()
|
|
15
|
+
environ.Env.read_env(env_file=BASE_DIR / '.env')
|
|
16
|
+
|
|
17
|
+
DEBUG = env.bool('DEBUG', default=False)
|
|
18
|
+
|
|
19
|
+
# SECURITY WARNING: keep the secret key used in production secret!
|
|
20
|
+
SECRET_KEY = env('SECRET_KEY', default='your-secret-key')
|
|
21
|
+
REFRESH_TOKEN_SECRET = env('SECRET_KEY', default='your-secret-key')
|
|
22
|
+
BASE_URL = env('BASE_URL', default='http://127.0.0.1:8000')
|
|
23
|
+
BACKEND_URL = env('BACKEND_URL', default='http://127.0.0.1:8000')
|
|
24
|
+
FRONTEND_URL = env('FRONTEND_URL', default='http://127.0.0.1:3000')
|
|
25
|
+
|
|
26
|
+
SENTRY_DSN = env('SENTRY_DSN', default='')
|
|
27
|
+
|
|
28
|
+
# ── Cloudflare R2 (S3-compatible) ────────────────────────────────
|
|
29
|
+
# All media is stored in Cloudflare R2. ``R2_PUBLIC_BASE_URL`` should
|
|
30
|
+
# be either the bucket's R2.dev subdomain or a custom domain bound to
|
|
31
|
+
# the bucket (this is what ends up in DB-persisted media URLs).
|
|
32
|
+
R2_ACCOUNT_ID = env('R2_ACCOUNT_ID', default='').strip()
|
|
33
|
+
R2_ACCESS_KEY_ID = env('R2_ACCESS_KEY_ID', default='').strip()
|
|
34
|
+
R2_SECRET_ACCESS_KEY = env('R2_SECRET_ACCESS_KEY', default='').strip()
|
|
35
|
+
R2_BUCKET = env('R2_BUCKET', default='miler-media').strip()
|
|
36
|
+
R2_REGION = env('R2_REGION', default='auto').strip()
|
|
37
|
+
R2_ENDPOINT = env(
|
|
38
|
+
'R2_ENDPOINT',
|
|
39
|
+
default=(
|
|
40
|
+
f'https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com'
|
|
41
|
+
if R2_ACCOUNT_ID else ''
|
|
42
|
+
),
|
|
43
|
+
).strip()
|
|
44
|
+
R2_PUBLIC_BASE_URL = env('R2_PUBLIC_BASE_URL', default='').strip()
|
|
45
|
+
if R2_PUBLIC_BASE_URL and not R2_PUBLIC_BASE_URL.startswith(('http://', 'https://')):
|
|
46
|
+
R2_PUBLIC_BASE_URL = f'https://{R2_PUBLIC_BASE_URL}'
|
|
47
|
+
R2_PUBLIC_BASE_URL = R2_PUBLIC_BASE_URL.rstrip('/')
|
|
48
|
+
R2_MEDIA_PATH = env('R2_MEDIA_PATH', default='').strip('/ ')
|
|
49
|
+
|
|
50
|
+
# Canonical public base for media URLs. Consumers (e.g. serializers
|
|
51
|
+
# validating that a URL lives on our own storage) should reference
|
|
52
|
+
# this rather than any backend-specific setting.
|
|
53
|
+
MEDIA_BASE_URL = R2_PUBLIC_BASE_URL
|
|
54
|
+
|
|
55
|
+
DATABASE_ENGINE = env('DATABASE_ENGINE', default='django.db.backends.sqlite3')
|
|
56
|
+
DATABASE_NAME = env('DATABASE_NAME', default='db.sqlite3')
|
|
57
|
+
DATABASE_USER = env('DATABASE_USER', default='your-database-user')
|
|
58
|
+
DATABASE_PASSWORD = env('DATABASE_PASSWORD', default='your-database-password')
|
|
59
|
+
DATABASE_HOST = env('DATABASE_HOST', default='localhost')
|
|
60
|
+
DATABASE_PORT = env('DATABASE_PORT', default='5432')
|
|
61
|
+
|
|
62
|
+
EMAIL_HOST = env('EMAIL_HOST', default='localhost')
|
|
63
|
+
EMAIL_HOST_USER = env('EMAIL_HOST_USER', default='your-email-user')
|
|
64
|
+
EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD', default='your-email-password')
|
|
65
|
+
EMAIL_PORT = env('EMAIL_PORT', default='5432')
|
|
66
|
+
DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='your-email@example.com')
|
|
67
|
+
|
|
68
|
+
# ── Azure Communication Services (Email) ──────────────────────────
|
|
69
|
+
# Production sends go through ACS when ``endpoint + access_key`` are
|
|
70
|
+
# configured. In dev / tests, leave them blank to fall back to
|
|
71
|
+
# ``console.EmailBackend``.
|
|
72
|
+
AZURE_COMMUNICATION_ENDPOINT = env(
|
|
73
|
+
'AZURE_COMMUNICATION_ENDPOINT', default='',
|
|
74
|
+
)
|
|
75
|
+
AZURE_COMMUNICATION_ACCESS_KEY = env(
|
|
76
|
+
'AZURE_COMMUNICATION_ACCESS_KEY', default='',
|
|
77
|
+
)
|
|
78
|
+
AZURE_COMMUNICATION_EMAIL_SENDER_NO_REPLY = env(
|
|
79
|
+
'AZURE_COMMUNICATION_EMAIL_SENDER_NO_REPLY', default='',
|
|
80
|
+
)
|
|
81
|
+
AZURE_COMMUNICATION_EMAIL_SENDER_NOTIFICATION = env(
|
|
82
|
+
'AZURE_COMMUNICATION_EMAIL_SENDER_NOTIFICATION', default='',
|
|
83
|
+
)
|
|
84
|
+
AZURE_COMMUNICATION_EMAIL_SENDER_SUPPORT = env(
|
|
85
|
+
'AZURE_COMMUNICATION_EMAIL_SENDER_SUPPORT', default='',
|
|
86
|
+
)
|
|
87
|
+
AZURE_COMMUNICATION_EMAIL_SENDER_MARKETING = env(
|
|
88
|
+
'AZURE_COMMUNICATION_EMAIL_SENDER_MARKETING', default='',
|
|
89
|
+
)
|
|
90
|
+
# Legacy single-sender env kept as a fallback. Most events should pick
|
|
91
|
+
# a purpose-specific sender via ``send_template_email(sender='...')``.
|
|
92
|
+
AZURE_COMMUNICATION_EMAIL_SENDER = env(
|
|
93
|
+
'AZURE_COMMUNICATION_EMAIL_SENDER',
|
|
94
|
+
default=(
|
|
95
|
+
AZURE_COMMUNICATION_EMAIL_SENDER_NOTIFICATION
|
|
96
|
+
or AZURE_COMMUNICATION_EMAIL_SENDER_NO_REPLY
|
|
97
|
+
or AZURE_COMMUNICATION_EMAIL_SENDER_SUPPORT
|
|
98
|
+
or AZURE_COMMUNICATION_EMAIL_SENDER_MARKETING
|
|
99
|
+
or ''
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Canonical map used by ``core.services.azure.email`` to resolve a
|
|
104
|
+
# purpose keyword (``no_reply`` / ``notification`` / ``support`` /
|
|
105
|
+
# ``marketing``) to a concrete MailFrom address. Each slot falls back
|
|
106
|
+
# to the single-sender value so partial configs keep working.
|
|
107
|
+
AZURE_COMMUNICATION_EMAIL_SENDERS = {
|
|
108
|
+
'no_reply': AZURE_COMMUNICATION_EMAIL_SENDER_NO_REPLY or AZURE_COMMUNICATION_EMAIL_SENDER,
|
|
109
|
+
'notification': AZURE_COMMUNICATION_EMAIL_SENDER_NOTIFICATION or AZURE_COMMUNICATION_EMAIL_SENDER,
|
|
110
|
+
'support': AZURE_COMMUNICATION_EMAIL_SENDER_SUPPORT or AZURE_COMMUNICATION_EMAIL_SENDER,
|
|
111
|
+
'marketing': AZURE_COMMUNICATION_EMAIL_SENDER_MARKETING or AZURE_COMMUNICATION_EMAIL_SENDER,
|
|
112
|
+
}
|
|
113
|
+
AZURE_COMMUNICATION_EMAIL_DEFAULT_SENDER_KEY = env(
|
|
114
|
+
'AZURE_COMMUNICATION_EMAIL_DEFAULT_SENDER_KEY', default='notification',
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
AZURE_COMMUNICATION_EMAIL_POLLING_INTERVAL_SECONDS = env.int(
|
|
118
|
+
'AZURE_COMMUNICATION_EMAIL_POLLING_INTERVAL_SECONDS', default=2,
|
|
119
|
+
)
|
|
120
|
+
AZURE_COMMUNICATION_EMAIL_TIMEOUT_SECONDS = env.int(
|
|
121
|
+
'AZURE_COMMUNICATION_EMAIL_TIMEOUT_SECONDS', default=60,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
_ACS_HAS_CREDENTIALS = bool(
|
|
125
|
+
AZURE_COMMUNICATION_ENDPOINT and AZURE_COMMUNICATION_ACCESS_KEY
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if _ACS_HAS_CREDENTIALS and AZURE_COMMUNICATION_EMAIL_SENDER:
|
|
129
|
+
EMAIL_BACKEND = (
|
|
130
|
+
'core.services.azure.email.backend.AzureCommunicationEmailBackend'
|
|
131
|
+
)
|
|
132
|
+
# Honour the ACS sender as the default From if none was set explicitly.
|
|
133
|
+
if DEFAULT_FROM_EMAIL == 'your-email@example.com':
|
|
134
|
+
DEFAULT_FROM_EMAIL = AZURE_COMMUNICATION_EMAIL_SENDER
|
|
135
|
+
else:
|
|
136
|
+
EMAIL_BACKEND = env(
|
|
137
|
+
'EMAIL_BACKEND',
|
|
138
|
+
default='django.core.mail.backends.console.EmailBackend',
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
JWT_ACCESS_TTL_MINUTES = env.int('JWT_ACCESS_TTL_MINUTES', default=15)
|
|
142
|
+
|
|
143
|
+
REDIS_HOST = env('REDIS_HOST', default='localhost')
|
|
144
|
+
REDIS_USER = env('REDIS_USER', default='your-redis-user')
|
|
145
|
+
REDIS_PASSWORD = env('REDIS_PASSWORD', default='your-redis-password')
|
|
146
|
+
REDIS_PORT = env('REDIS_PORT', default='6379')
|
|
147
|
+
|
|
148
|
+
BEEM_API_KEY = env('BEEM_API_KEY', default='your-beem-api-key')
|
|
149
|
+
SELCOM_MERCHANT_ID = env('SELCOM_MERCHANT_ID', default='your-selcom-merchant-id')
|
|
150
|
+
CLICKPESA_CLIENT_ID = env('CLICKPESA_CLIENT_ID', default='your-clickpesa-client-id')
|
|
151
|
+
POLAR_API_KEY = env('POLAR_API_KEY', default='your-polar-api-key')
|
|
152
|
+
|
|
153
|
+
ALLOWED_HOSTS = ['*']
|
|
154
|
+
|
|
155
|
+
CORS_ORIGIN_ALLOW_ALL = False
|
|
156
|
+
CORS_ALLOW_CREDENTIALS = True
|
|
157
|
+
CORS_ORIGIN_WHITELIST = (
|
|
158
|
+
'http://127.0.0.1:3000',
|
|
159
|
+
'http://127.0.0.1:8000',
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
SIMPLE_JWT = {
|
|
164
|
+
"TOKEN_OBTAIN_SERIALIZER": "nexent.auth_serializer.TokenObtainPairSerializer",
|
|
165
|
+
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=JWT_ACCESS_TTL_MINUTES),
|
|
166
|
+
'REFRESH_TOKEN_LIFETIME': timedelta(days=14),
|
|
167
|
+
'ROTATE_REFRESH_TOKENS': False,
|
|
168
|
+
'BLACKLIST_AFTER_ROTATION': False,
|
|
169
|
+
'UPDATE_LAST_LOGIN': True,
|
|
170
|
+
|
|
171
|
+
'ALGORITHM': 'HS256',
|
|
172
|
+
'SIGNING_KEY': SECRET_KEY,
|
|
173
|
+
'VERIFYING_KEY': None,
|
|
174
|
+
'AUDIENCE': None,
|
|
175
|
+
'ISSUER': None,
|
|
176
|
+
'JWK_URL': None,
|
|
177
|
+
'LEEWAY': 0,
|
|
178
|
+
|
|
179
|
+
'AUTH_HEADER_TYPES': ('Bearer',),
|
|
180
|
+
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
|
|
181
|
+
'USER_ID_FIELD': 'id',
|
|
182
|
+
'USER_ID_CLAIM': 'user_id',
|
|
183
|
+
'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule',
|
|
184
|
+
|
|
185
|
+
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
|
|
186
|
+
'TOKEN_TYPE_CLAIM': 'token_type',
|
|
187
|
+
'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser',
|
|
188
|
+
|
|
189
|
+
'JTI_CLAIM': 'jti',
|
|
190
|
+
|
|
191
|
+
'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
|
|
192
|
+
'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
|
|
193
|
+
'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
|
|
194
|
+
|
|
195
|
+
'AUTH_COOKIE': 'access_token', # Cookie name. Enables cookies if value is set.
|
|
196
|
+
'AUTH_COOKIE_DOMAIN': None, # A string like "example.com", or None for standard domain cookie.
|
|
197
|
+
'AUTH_COOKIE_SECURE': False, # Whether the auth cookies should be secure (https:// only).
|
|
198
|
+
'AUTH_COOKIE_HTTP_ONLY' : True, # Http only cookie flag.It's not fetch by javascript.
|
|
199
|
+
'AUTH_COOKIE_PATH': '/', # The path of the auth cookie.
|
|
200
|
+
'AUTH_COOKIE_SAMESITE': 'Lax', # Whether to set the flag restricting cookie leaks on cross-site requests. This can be 'Lax', 'Strict', or None to disable the flag.
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
INSTALLED_APPS = [
|
|
204
|
+
"django.contrib.admin",
|
|
205
|
+
"django.contrib.auth",
|
|
206
|
+
"django.contrib.contenttypes",
|
|
207
|
+
"django.contrib.sessions",
|
|
208
|
+
"django.contrib.messages",
|
|
209
|
+
"django.contrib.staticfiles",
|
|
210
|
+
'rest_framework',
|
|
211
|
+
'rest_framework.authtoken',
|
|
212
|
+
'rest_framework_simplejwt',
|
|
213
|
+
'rest_framework_simplejwt.token_blacklist',
|
|
214
|
+
'django_celery_beat',
|
|
215
|
+
'corsheaders',
|
|
216
|
+
# Project apps
|
|
217
|
+
'apps.common',
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
MIDDLEWARE = [
|
|
221
|
+
'django.middleware.security.SecurityMiddleware',
|
|
222
|
+
'django.contrib.sessions.middleware.SessionMiddleware',
|
|
223
|
+
'django.middleware.common.CommonMiddleware',
|
|
224
|
+
'django.middleware.csrf.CsrfViewMiddleware',
|
|
225
|
+
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
|
226
|
+
'django.contrib.messages.middleware.MessageMiddleware',
|
|
227
|
+
'corsheaders.middleware.CorsMiddleware',
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
ROOT_URLCONF = '{project_name}.urls'
|
|
231
|
+
WSGI_APPLICATION = '{project_name}.wsgi.application'
|
|
232
|
+
ASGI_APPLICATION = '{project_name}.asgi.application'
|
|
233
|
+
|
|
234
|
+
TEMPLATES = [
|
|
235
|
+
{
|
|
236
|
+
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
237
|
+
"DIRS": [],
|
|
238
|
+
"APP_DIRS": True,
|
|
239
|
+
"OPTIONS": {
|
|
240
|
+
"context_processors": [
|
|
241
|
+
"django.template.context_processors.debug",
|
|
242
|
+
"django.template.context_processors.request",
|
|
243
|
+
"django.contrib.auth.context_processors.auth",
|
|
244
|
+
"django.contrib.messages.context_processors.messages",
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
DATABASES = {
|
|
251
|
+
'default': {
|
|
252
|
+
'ENGINE': DATABASE_ENGINE,
|
|
253
|
+
'NAME': DATABASE_NAME,
|
|
254
|
+
'USER': DATABASE_USER,
|
|
255
|
+
'PASSWORD': DATABASE_PASSWORD,
|
|
256
|
+
'HOST': DATABASE_HOST,
|
|
257
|
+
'PORT': DATABASE_PORT,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
AUTH_PASSWORD_VALIDATORS = [
|
|
262
|
+
{
|
|
263
|
+
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
|
273
|
+
},
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
REST_FRAMEWORK = {
|
|
277
|
+
'DEFAULT_AUTHENTICATION_CLASSES': [
|
|
278
|
+
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
|
279
|
+
],
|
|
280
|
+
'DEFAULT_RENDERER_CLASSES': [
|
|
281
|
+
'rest_framework.renderers.JSONRenderer',
|
|
282
|
+
]
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
LANGUAGE_CODE = "en-us"
|
|
286
|
+
|
|
287
|
+
TIME_ZONE = "UTC"
|
|
288
|
+
|
|
289
|
+
USE_I18N = True
|
|
290
|
+
|
|
291
|
+
USE_L10N = True
|
|
292
|
+
|
|
293
|
+
USE_TZ = True
|
|
294
|
+
|
|
295
|
+
STATIC_URL = '/static/'
|
|
296
|
+
|
|
297
|
+
if not DEBUG:
|
|
298
|
+
SESSION_COOKIE_SECURE = True
|
|
299
|
+
CSRF_COOKIE_SECURE = True
|
|
300
|
+
SECURE_SSL_REDIRECT = True
|
|
301
|
+
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
|
302
|
+
|
|
303
|
+
# ── Sentry Configuration ───────────────────────────────────────────
|
|
304
|
+
sentry_logging = LoggingIntegration(
|
|
305
|
+
level=None, # Capture all log levels
|
|
306
|
+
event_level=None, # Send all log levels as events
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if SENTRY_DSN:
|
|
310
|
+
sentry_sdk.init(
|
|
311
|
+
dsn=SENTRY_DSN,
|
|
312
|
+
# Add data like request headers and IP for users,
|
|
313
|
+
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
|
|
314
|
+
send_default_pii=True,
|
|
315
|
+
enable_logs=True,
|
|
316
|
+
integrations=[
|
|
317
|
+
sentry_logging,
|
|
318
|
+
DjangoIntegration(),
|
|
319
|
+
],
|
|
320
|
+
traces_sample_rate=1.0,
|
|
321
|
+
profiles_sample_rate=1.0,
|
|
322
|
+
)
|