vibetuner 2.26.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vibetuner/__init__.py +2 -0
- vibetuner/__main__.py +4 -0
- vibetuner/cli/__init__.py +141 -0
- vibetuner/cli/run.py +160 -0
- vibetuner/cli/scaffold.py +187 -0
- vibetuner/config.py +143 -0
- vibetuner/context.py +28 -0
- vibetuner/frontend/__init__.py +107 -0
- vibetuner/frontend/deps.py +41 -0
- vibetuner/frontend/email.py +45 -0
- vibetuner/frontend/hotreload.py +13 -0
- vibetuner/frontend/lifespan.py +37 -0
- vibetuner/frontend/middleware.py +151 -0
- vibetuner/frontend/oauth.py +196 -0
- vibetuner/frontend/routes/__init__.py +12 -0
- vibetuner/frontend/routes/auth.py +156 -0
- vibetuner/frontend/routes/debug.py +414 -0
- vibetuner/frontend/routes/health.py +37 -0
- vibetuner/frontend/routes/language.py +43 -0
- vibetuner/frontend/routes/meta.py +55 -0
- vibetuner/frontend/routes/user.py +94 -0
- vibetuner/frontend/templates.py +176 -0
- vibetuner/logging.py +87 -0
- vibetuner/models/__init__.py +14 -0
- vibetuner/models/blob.py +89 -0
- vibetuner/models/email_verification.py +84 -0
- vibetuner/models/mixins.py +76 -0
- vibetuner/models/oauth.py +57 -0
- vibetuner/models/registry.py +15 -0
- vibetuner/models/types.py +16 -0
- vibetuner/models/user.py +91 -0
- vibetuner/mongo.py +33 -0
- vibetuner/paths.py +250 -0
- vibetuner/services/__init__.py +0 -0
- vibetuner/services/blob.py +175 -0
- vibetuner/services/email.py +50 -0
- vibetuner/tasks/__init__.py +0 -0
- vibetuner/tasks/lifespan.py +28 -0
- vibetuner/tasks/worker.py +15 -0
- vibetuner/templates/email/magic_link.html.jinja +17 -0
- vibetuner/templates/email/magic_link.txt.jinja +5 -0
- vibetuner/templates/frontend/base/favicons.html.jinja +1 -0
- vibetuner/templates/frontend/base/footer.html.jinja +3 -0
- vibetuner/templates/frontend/base/header.html.jinja +0 -0
- vibetuner/templates/frontend/base/opengraph.html.jinja +7 -0
- vibetuner/templates/frontend/base/skeleton.html.jinja +45 -0
- vibetuner/templates/frontend/debug/collections.html.jinja +105 -0
- vibetuner/templates/frontend/debug/components/debug_nav.html.jinja +55 -0
- vibetuner/templates/frontend/debug/index.html.jinja +85 -0
- vibetuner/templates/frontend/debug/info.html.jinja +258 -0
- vibetuner/templates/frontend/debug/users.html.jinja +139 -0
- vibetuner/templates/frontend/debug/version.html.jinja +55 -0
- vibetuner/templates/frontend/email/magic_link.txt.jinja +5 -0
- vibetuner/templates/frontend/email_sent.html.jinja +83 -0
- vibetuner/templates/frontend/index.html.jinja +20 -0
- vibetuner/templates/frontend/lang/select.html.jinja +4 -0
- vibetuner/templates/frontend/login.html.jinja +89 -0
- vibetuner/templates/frontend/meta/browserconfig.xml.jinja +10 -0
- vibetuner/templates/frontend/meta/robots.txt.jinja +3 -0
- vibetuner/templates/frontend/meta/site.webmanifest.jinja +7 -0
- vibetuner/templates/frontend/meta/sitemap.xml.jinja +6 -0
- vibetuner/templates/frontend/user/edit.html.jinja +86 -0
- vibetuner/templates/frontend/user/profile.html.jinja +157 -0
- vibetuner/templates/markdown/.placeholder +0 -0
- vibetuner/templates.py +146 -0
- vibetuner/time.py +57 -0
- vibetuner/versioning.py +12 -0
- vibetuner-2.26.6.dist-info/METADATA +241 -0
- vibetuner-2.26.6.dist-info/RECORD +71 -0
- vibetuner-2.26.6.dist-info/WHEEL +4 -0
- vibetuner-2.26.6.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Common type definitions for models.
|
|
2
|
+
|
|
3
|
+
WARNING: This is a scaffolding-managed file. DO NOT MODIFY directly.
|
|
4
|
+
Provides type aliases and re-exports for consistent typing across models.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING, TypeAlias, TypeVar
|
|
8
|
+
|
|
9
|
+
from beanie import Document, Link as BeanieLink
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
_T = TypeVar("_T", bound=Document)
|
|
14
|
+
Link: TypeAlias = _T
|
|
15
|
+
else:
|
|
16
|
+
Link = BeanieLink
|
vibetuner/models/user.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Core user model for authentication and user management.
|
|
2
|
+
|
|
3
|
+
WARNING: This is a scaffolding-managed file. DO NOT MODIFY directly.
|
|
4
|
+
Extend functionality by creating custom models that reference or extend these models.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
from typing import Any, List, Self
|
|
9
|
+
|
|
10
|
+
from beanie import Document
|
|
11
|
+
from beanie.operators import Eq
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
from pydantic_extra_types.language_code import LanguageAlpha2
|
|
14
|
+
|
|
15
|
+
from vibetuner.models.registry import register_model
|
|
16
|
+
|
|
17
|
+
from .mixins import TimeStampMixin
|
|
18
|
+
from .oauth import OAuthAccountModel
|
|
19
|
+
from .types import Link
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UserSettings(BaseModel):
|
|
23
|
+
"""User settings for the application.
|
|
24
|
+
|
|
25
|
+
This class holds the default settings for the user, such as language and theme.
|
|
26
|
+
It can be extended to include more user-specific settings in the future.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
language: LanguageAlpha2 | None = Field(
|
|
30
|
+
default=None,
|
|
31
|
+
description="Preferred language for the user",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@cached_property
|
|
35
|
+
def session_dict(self) -> dict[str, Any]:
|
|
36
|
+
"""Return a dictionary representation of the user settings for session storage.
|
|
37
|
+
|
|
38
|
+
Make sure to only include fields that are necessary for the session.
|
|
39
|
+
"""
|
|
40
|
+
return self.model_dump(
|
|
41
|
+
exclude_none=True,
|
|
42
|
+
exclude_unset=True,
|
|
43
|
+
include={
|
|
44
|
+
"language",
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@register_model
|
|
50
|
+
class UserModel(Document, TimeStampMixin):
|
|
51
|
+
email: str | None = Field(
|
|
52
|
+
default=None,
|
|
53
|
+
description="Primary email address for authentication",
|
|
54
|
+
)
|
|
55
|
+
name: str | None = Field(
|
|
56
|
+
default=None,
|
|
57
|
+
description="User's full display name",
|
|
58
|
+
)
|
|
59
|
+
picture: str | None = Field(
|
|
60
|
+
default=None,
|
|
61
|
+
description="URL to user's profile picture or avatar",
|
|
62
|
+
)
|
|
63
|
+
oauth_accounts: List[Link[OAuthAccountModel]] = Field(
|
|
64
|
+
default_factory=list,
|
|
65
|
+
description="Connected OAuth provider accounts (Google, GitHub, etc.)",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
user_settings: UserSettings = Field(
|
|
69
|
+
default_factory=UserSettings,
|
|
70
|
+
description="User-specific settings for the application",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
class Settings:
|
|
74
|
+
name = "users"
|
|
75
|
+
keep_nulls = False
|
|
76
|
+
|
|
77
|
+
@cached_property
|
|
78
|
+
def session_dict(self) -> dict[str, Any]:
|
|
79
|
+
return {
|
|
80
|
+
"id": str(self.id),
|
|
81
|
+
**self.model_dump(
|
|
82
|
+
exclude_none=True,
|
|
83
|
+
exclude_unset=True,
|
|
84
|
+
include={"name", "email", "picture"},
|
|
85
|
+
),
|
|
86
|
+
"settings": self.user_settings.session_dict,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
async def get_by_email(cls, email: str) -> Self | None:
|
|
91
|
+
return await cls.find_one(Eq(cls.email, email))
|
vibetuner/mongo.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
|
|
3
|
+
from beanie import init_beanie
|
|
4
|
+
from pymongo import AsyncMongoClient
|
|
5
|
+
|
|
6
|
+
from vibetuner.config import settings
|
|
7
|
+
from vibetuner.logging import logger
|
|
8
|
+
from vibetuner.models.registry import get_all_models
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def init_models() -> None:
|
|
12
|
+
"""Initialize MongoDB connection and register all Beanie models."""
|
|
13
|
+
|
|
14
|
+
# Try to import user models to trigger their registration
|
|
15
|
+
try:
|
|
16
|
+
import_module("app.models")
|
|
17
|
+
except ModuleNotFoundError:
|
|
18
|
+
# Silent pass for missing app.models module (expected in some projects)
|
|
19
|
+
pass
|
|
20
|
+
except ImportError as e:
|
|
21
|
+
# Log warning for any import error (including syntax errors, missing dependencies, etc.)
|
|
22
|
+
logger.warning(
|
|
23
|
+
f"Failed to import app.models: {e}. User models will not be registered."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
client: AsyncMongoClient = AsyncMongoClient(
|
|
27
|
+
host=str(settings.mongodb_url),
|
|
28
|
+
compressors=["zstd"],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
await init_beanie(
|
|
32
|
+
database=client[settings.mongo_dbname], document_models=get_all_models()
|
|
33
|
+
)
|
vibetuner/paths.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from importlib.resources import files
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Self
|
|
4
|
+
|
|
5
|
+
from pydantic import computed_field, model_validator
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
from vibetuner.logging import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Package-relative paths (for bundled templates in the vibetuner package)
|
|
12
|
+
_package_files = files("vibetuner")
|
|
13
|
+
_package_templates_traversable = _package_files / "templates"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_package_templates_path() -> Path:
|
|
17
|
+
"""Get package templates path, works for both installed and editable installs."""
|
|
18
|
+
try:
|
|
19
|
+
return Path(str(_package_templates_traversable))
|
|
20
|
+
except (TypeError, ValueError):
|
|
21
|
+
raise RuntimeError(
|
|
22
|
+
"Package templates are in a non-filesystem location. "
|
|
23
|
+
"This is not yet supported."
|
|
24
|
+
) from None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_core_templates_symlink(target: Path) -> None:
|
|
28
|
+
"""Create or update a 'core' symlink pointing to the package templates directory."""
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
source = _get_package_templates_path().resolve()
|
|
32
|
+
except RuntimeError as e:
|
|
33
|
+
logger.error(f"Cannot create symlink: {e}")
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
# Case 1: target does not exist → create symlink
|
|
37
|
+
if not target.exists():
|
|
38
|
+
target.symlink_to(source, target_is_directory=True)
|
|
39
|
+
logger.info(f"Created symlink '{target}' → '{source}'")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
# Case 2: exists but is not a symlink → error
|
|
43
|
+
if not target.is_symlink():
|
|
44
|
+
logger.error(f"Cannot create symlink: '{target}' exists and is not a symlink.")
|
|
45
|
+
raise FileExistsError(
|
|
46
|
+
f"Cannot create symlink: '{target}' exists and is not a symlink."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Case 3: is a symlink but points somewhere else → update it
|
|
50
|
+
if target.resolve() != source:
|
|
51
|
+
target.unlink()
|
|
52
|
+
target.symlink_to(source, target_is_directory=True)
|
|
53
|
+
logger.info(f"Updated symlink '{target}' → '{source}'")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Package templates always available
|
|
57
|
+
package_templates = _get_package_templates_path()
|
|
58
|
+
core_templates = package_templates # Alias for backwards compatibility
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PathSettings(BaseSettings):
|
|
62
|
+
"""Path settings with lazy auto-detection of project root."""
|
|
63
|
+
|
|
64
|
+
model_config = SettingsConfigDict(
|
|
65
|
+
case_sensitive=False,
|
|
66
|
+
extra="ignore",
|
|
67
|
+
validate_default=True,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
root: Path | None = None
|
|
71
|
+
fallback_path: str = "defaults"
|
|
72
|
+
|
|
73
|
+
@model_validator(mode="after")
|
|
74
|
+
def detect_project_root(self) -> Self:
|
|
75
|
+
"""Auto-detect project root if not explicitly set."""
|
|
76
|
+
if self.root is None:
|
|
77
|
+
detected = self._find_project_root()
|
|
78
|
+
if detected is not None:
|
|
79
|
+
self.root = detected
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _find_project_root() -> Path | None:
|
|
84
|
+
"""Find project root by searching for marker files."""
|
|
85
|
+
markers = [".copier-answers.yml", "pyproject.toml", ".git"]
|
|
86
|
+
current = Path.cwd()
|
|
87
|
+
|
|
88
|
+
for parent in [current, *current.parents]:
|
|
89
|
+
if any((parent / marker).exists() for marker in markers):
|
|
90
|
+
return parent
|
|
91
|
+
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
# Project-specific paths (only available if root is set)
|
|
95
|
+
@computed_field
|
|
96
|
+
@property
|
|
97
|
+
def templates(self) -> Path | None:
|
|
98
|
+
"""Project templates directory."""
|
|
99
|
+
return self.root / "templates" if self.root else None
|
|
100
|
+
|
|
101
|
+
@computed_field
|
|
102
|
+
@property
|
|
103
|
+
def app_templates(self) -> Path | None:
|
|
104
|
+
"""Deprecated: use templates instead."""
|
|
105
|
+
return self.templates
|
|
106
|
+
|
|
107
|
+
@computed_field
|
|
108
|
+
@property
|
|
109
|
+
def locales(self) -> Path | None:
|
|
110
|
+
"""Project locales directory."""
|
|
111
|
+
return self.root / "locales" if self.root else None
|
|
112
|
+
|
|
113
|
+
@computed_field
|
|
114
|
+
@property
|
|
115
|
+
def config_vars(self) -> Path | None:
|
|
116
|
+
"""Copier answers file."""
|
|
117
|
+
return self.root / ".copier-answers.yml" if self.root else None
|
|
118
|
+
|
|
119
|
+
@computed_field
|
|
120
|
+
@property
|
|
121
|
+
def assets(self) -> Path | None:
|
|
122
|
+
"""Project assets directory."""
|
|
123
|
+
return self.root / "assets" if self.root else None
|
|
124
|
+
|
|
125
|
+
@computed_field
|
|
126
|
+
@property
|
|
127
|
+
def statics(self) -> Path | None:
|
|
128
|
+
"""Project static assets directory."""
|
|
129
|
+
return self.root / "assets" / "statics" if self.root else None
|
|
130
|
+
|
|
131
|
+
@computed_field
|
|
132
|
+
@property
|
|
133
|
+
def css(self) -> Path | None:
|
|
134
|
+
"""Project CSS directory."""
|
|
135
|
+
return self.root / "assets" / "statics" / "css" if self.root else None
|
|
136
|
+
|
|
137
|
+
@computed_field
|
|
138
|
+
@property
|
|
139
|
+
def js(self) -> Path | None:
|
|
140
|
+
"""Project JavaScript directory."""
|
|
141
|
+
return self.root / "assets" / "statics" / "js" if self.root else None
|
|
142
|
+
|
|
143
|
+
@computed_field
|
|
144
|
+
@property
|
|
145
|
+
def favicons(self) -> Path | None:
|
|
146
|
+
"""Project favicons directory."""
|
|
147
|
+
return self.root / "assets" / "statics" / "favicons" if self.root else None
|
|
148
|
+
|
|
149
|
+
@computed_field
|
|
150
|
+
@property
|
|
151
|
+
def img(self) -> Path | None:
|
|
152
|
+
"""Project images directory."""
|
|
153
|
+
return self.root / "assets" / "statics" / "img" if self.root else None
|
|
154
|
+
|
|
155
|
+
# Template paths (always return a list, project + package)
|
|
156
|
+
@computed_field
|
|
157
|
+
@property
|
|
158
|
+
def frontend_templates(self) -> list[Path]:
|
|
159
|
+
"""Frontend template search paths (project overrides, then package)."""
|
|
160
|
+
paths = []
|
|
161
|
+
if self.root:
|
|
162
|
+
project_path = self.root / "templates" / "frontend"
|
|
163
|
+
if project_path.exists():
|
|
164
|
+
paths.append(project_path)
|
|
165
|
+
paths.append(package_templates / "frontend")
|
|
166
|
+
return paths
|
|
167
|
+
|
|
168
|
+
@computed_field
|
|
169
|
+
@property
|
|
170
|
+
def email_templates(self) -> list[Path]:
|
|
171
|
+
"""Email template search paths (project overrides, then package)."""
|
|
172
|
+
paths = []
|
|
173
|
+
if self.root:
|
|
174
|
+
project_path = self.root / "templates" / "email"
|
|
175
|
+
if project_path.exists():
|
|
176
|
+
paths.append(project_path)
|
|
177
|
+
paths.append(package_templates / "email")
|
|
178
|
+
return paths
|
|
179
|
+
|
|
180
|
+
@computed_field
|
|
181
|
+
@property
|
|
182
|
+
def markdown_templates(self) -> list[Path]:
|
|
183
|
+
"""Markdown template search paths (project overrides, then package)."""
|
|
184
|
+
paths = []
|
|
185
|
+
if self.root:
|
|
186
|
+
project_path = self.root / "templates" / "markdown"
|
|
187
|
+
if project_path.exists():
|
|
188
|
+
paths.append(project_path)
|
|
189
|
+
paths.append(package_templates / "markdown")
|
|
190
|
+
return paths
|
|
191
|
+
|
|
192
|
+
def to_template_path_list(self, path: Path) -> list[Path]:
|
|
193
|
+
"""Convert path to list with fallback."""
|
|
194
|
+
return [path, path / self.fallback_path]
|
|
195
|
+
|
|
196
|
+
def fallback_static_default(self, static_type: str, file_name: str) -> Path:
|
|
197
|
+
"""Return a fallback path for a static file."""
|
|
198
|
+
if self.statics is None:
|
|
199
|
+
raise RuntimeError(
|
|
200
|
+
"Project root not detected. Cannot access static assets."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
paths_to_check = [
|
|
204
|
+
self.statics / static_type / file_name,
|
|
205
|
+
self.statics / self.fallback_path / static_type / file_name,
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
for path in paths_to_check:
|
|
209
|
+
if path.exists():
|
|
210
|
+
return path
|
|
211
|
+
|
|
212
|
+
raise FileNotFoundError(
|
|
213
|
+
f"Could not find {file_name} in any of the fallback paths: {paths_to_check}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# Global settings instance with lazy auto-detection
|
|
218
|
+
_settings = PathSettings()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def to_template_path_list(path: Path) -> list[Path]:
|
|
222
|
+
"""Convert path to list with fallback."""
|
|
223
|
+
return _settings.to_template_path_list(path)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def fallback_static_default(static_type: str, file_name: str) -> Path:
|
|
227
|
+
"""Return a fallback path for a static file."""
|
|
228
|
+
return _settings.fallback_static_default(static_type, file_name)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# Expose settings instance for direct access
|
|
232
|
+
paths = _settings
|
|
233
|
+
|
|
234
|
+
# Module-level variables that delegate to settings (backwards compatibility)
|
|
235
|
+
# Access like: from vibetuner.paths import frontend_templates
|
|
236
|
+
# Or better: from vibetuner.paths import paths; paths.frontend_templates
|
|
237
|
+
root = _settings.root
|
|
238
|
+
templates = _settings.templates
|
|
239
|
+
app_templates = _settings.app_templates
|
|
240
|
+
locales = _settings.locales
|
|
241
|
+
config_vars = _settings.config_vars
|
|
242
|
+
assets = _settings.assets
|
|
243
|
+
statics = _settings.statics
|
|
244
|
+
css = _settings.css
|
|
245
|
+
js = _settings.js
|
|
246
|
+
favicons = _settings.favicons
|
|
247
|
+
img = _settings.img
|
|
248
|
+
frontend_templates = _settings.frontend_templates
|
|
249
|
+
email_templates = _settings.email_templates
|
|
250
|
+
markdown_templates = _settings.markdown_templates
|
|
File without changes
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Blob storage service for file uploads to S3 or R2.
|
|
2
|
+
|
|
3
|
+
WARNING: This is a scaffolding-managed file. DO NOT MODIFY directly.
|
|
4
|
+
To extend blob functionality, create wrapper services in the parent services directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import mimetypes
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
import aioboto3
|
|
12
|
+
from aiobotocore.config import AioConfig
|
|
13
|
+
|
|
14
|
+
from vibetuner.config import settings
|
|
15
|
+
from vibetuner.models import BlobModel
|
|
16
|
+
from vibetuner.models.blob import BlobStatus
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
S3_SERVICE_NAME: Literal["s3"] = "s3"
|
|
20
|
+
DEFAULT_CONTENT_TYPE: str = "application/octet-stream"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BlobService:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
session: aioboto3.Session | None = None,
|
|
27
|
+
default_bucket: str | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
if (
|
|
30
|
+
settings.r2_bucket_endpoint_url is None
|
|
31
|
+
or settings.r2_access_key is None
|
|
32
|
+
or settings.r2_secret_key is None
|
|
33
|
+
):
|
|
34
|
+
raise ValueError(
|
|
35
|
+
"R2 bucket endpoint URL, access key, and secret key must be set in settings."
|
|
36
|
+
)
|
|
37
|
+
self.session = session or aioboto3.Session(
|
|
38
|
+
aws_access_key_id=settings.r2_access_key.get_secret_value(),
|
|
39
|
+
aws_secret_access_key=settings.r2_secret_key.get_secret_value(),
|
|
40
|
+
region_name=settings.r2_default_region,
|
|
41
|
+
)
|
|
42
|
+
self.endpoint_url = str(settings.r2_bucket_endpoint_url)
|
|
43
|
+
self.config = AioConfig(
|
|
44
|
+
request_checksum_calculation="when_required",
|
|
45
|
+
response_checksum_validation="when_required",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if not default_bucket:
|
|
49
|
+
if settings.r2_default_bucket_name is None:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"Default bucket name must be provided either in settings or as an argument."
|
|
52
|
+
)
|
|
53
|
+
self.default_bucket = settings.r2_default_bucket_name
|
|
54
|
+
else:
|
|
55
|
+
self.default_bucket = default_bucket
|
|
56
|
+
|
|
57
|
+
async def put_object(
|
|
58
|
+
self,
|
|
59
|
+
body: bytes,
|
|
60
|
+
content_type: str = DEFAULT_CONTENT_TYPE,
|
|
61
|
+
bucket: str | None = None,
|
|
62
|
+
namespace: str | None = None,
|
|
63
|
+
original_filename: str | None = None,
|
|
64
|
+
) -> BlobModel:
|
|
65
|
+
"""Put an object into the R2 bucket and return the blob model"""
|
|
66
|
+
|
|
67
|
+
bucket = bucket or self.default_bucket
|
|
68
|
+
|
|
69
|
+
blob = BlobModel.from_bytes(
|
|
70
|
+
body=body,
|
|
71
|
+
content_type=content_type,
|
|
72
|
+
bucket=bucket,
|
|
73
|
+
namespace=namespace,
|
|
74
|
+
original_filename=original_filename,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
await blob.insert()
|
|
78
|
+
|
|
79
|
+
if not blob.id:
|
|
80
|
+
raise ValueError("Blob ID must be set before uploading to R2.")
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
async with self.session.client(
|
|
84
|
+
service_name=S3_SERVICE_NAME,
|
|
85
|
+
endpoint_url=self.endpoint_url,
|
|
86
|
+
config=self.config,
|
|
87
|
+
) as s3_client:
|
|
88
|
+
await s3_client.put_object(
|
|
89
|
+
Bucket=bucket,
|
|
90
|
+
Key=blob.full_path,
|
|
91
|
+
Body=body,
|
|
92
|
+
ContentType=content_type,
|
|
93
|
+
)
|
|
94
|
+
blob.status = BlobStatus.UPLOADED
|
|
95
|
+
except Exception:
|
|
96
|
+
blob.status = BlobStatus.ERROR
|
|
97
|
+
finally:
|
|
98
|
+
await blob.save()
|
|
99
|
+
|
|
100
|
+
return blob
|
|
101
|
+
|
|
102
|
+
async def put_object_with_extension(
|
|
103
|
+
self,
|
|
104
|
+
body: bytes,
|
|
105
|
+
extension: str,
|
|
106
|
+
bucket: str | None = None,
|
|
107
|
+
namespace: str | None = None,
|
|
108
|
+
) -> BlobModel:
|
|
109
|
+
"""Put an object into the R2 bucket with content type guessed from extension"""
|
|
110
|
+
content_type, _ = mimetypes.guess_type(f"file.{extension.lstrip('.')}")
|
|
111
|
+
content_type = content_type or DEFAULT_CONTENT_TYPE
|
|
112
|
+
|
|
113
|
+
return await self.put_object(body, content_type, bucket, namespace)
|
|
114
|
+
|
|
115
|
+
async def put_file(
|
|
116
|
+
self,
|
|
117
|
+
file_path: Path | str,
|
|
118
|
+
content_type: str | None = None,
|
|
119
|
+
bucket: str | None = None,
|
|
120
|
+
namespace: str | None = None,
|
|
121
|
+
) -> BlobModel:
|
|
122
|
+
"""Put a file from filesystem into the R2 bucket"""
|
|
123
|
+
file_path = Path(file_path)
|
|
124
|
+
|
|
125
|
+
if not file_path.exists():
|
|
126
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
127
|
+
|
|
128
|
+
# Auto-detect content type if not provided
|
|
129
|
+
if content_type is None:
|
|
130
|
+
content_type, _ = mimetypes.guess_type(str(file_path))
|
|
131
|
+
content_type = content_type or DEFAULT_CONTENT_TYPE
|
|
132
|
+
|
|
133
|
+
return await self.put_object(
|
|
134
|
+
file_path.read_bytes(),
|
|
135
|
+
content_type,
|
|
136
|
+
bucket,
|
|
137
|
+
namespace,
|
|
138
|
+
original_filename=file_path.name,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
async def get_object(self, key: str) -> bytes:
|
|
142
|
+
"""Retrieve an object from the R2 bucket"""
|
|
143
|
+
blob = await BlobModel.get(key)
|
|
144
|
+
if not blob:
|
|
145
|
+
raise ValueError(f"Blob not found: {key}")
|
|
146
|
+
|
|
147
|
+
async with self.session.client(
|
|
148
|
+
service_name=S3_SERVICE_NAME,
|
|
149
|
+
endpoint_url=self.endpoint_url,
|
|
150
|
+
config=self.config,
|
|
151
|
+
) as s3_client:
|
|
152
|
+
response = await s3_client.get_object(
|
|
153
|
+
Bucket=blob.bucket,
|
|
154
|
+
Key=blob.full_path,
|
|
155
|
+
)
|
|
156
|
+
return await response["Body"].read()
|
|
157
|
+
|
|
158
|
+
async def delete_object(self, key: str) -> None:
|
|
159
|
+
"""Delete an object from the R2 bucket"""
|
|
160
|
+
blob = await BlobModel.get(key)
|
|
161
|
+
if not blob:
|
|
162
|
+
raise ValueError(f"Blob not found: {key}")
|
|
163
|
+
|
|
164
|
+
blob.status = BlobStatus.DELETED
|
|
165
|
+
|
|
166
|
+
await blob.save()
|
|
167
|
+
|
|
168
|
+
async def object_exists(self, key: str, check_bucket: bool = False) -> bool:
|
|
169
|
+
"""Check if an object exists in the R2 bucket"""
|
|
170
|
+
|
|
171
|
+
blob = await BlobModel.get(key)
|
|
172
|
+
if not blob:
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
return True
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Email service for sending transactional emails via AWS SES.
|
|
2
|
+
|
|
3
|
+
WARNING: This is a scaffolding-managed file. DO NOT MODIFY directly.
|
|
4
|
+
To extend email functionality, create wrapper services in the parent services directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
import boto3
|
|
10
|
+
|
|
11
|
+
from vibetuner.config import settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SES_SERVICE_NAME: Literal["ses"] = "ses"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SESEmailService:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
ses_client=None,
|
|
21
|
+
from_email: str | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.ses_client = ses_client or boto3.client(
|
|
24
|
+
service_name=SES_SERVICE_NAME,
|
|
25
|
+
region_name=settings.project.aws_default_region,
|
|
26
|
+
aws_access_key_id=settings.aws_access_key_id.get_secret_value()
|
|
27
|
+
if settings.aws_access_key_id
|
|
28
|
+
else None,
|
|
29
|
+
aws_secret_access_key=settings.aws_secret_access_key.get_secret_value()
|
|
30
|
+
if settings.aws_secret_access_key
|
|
31
|
+
else None,
|
|
32
|
+
)
|
|
33
|
+
self.from_email = from_email or settings.project.from_email
|
|
34
|
+
|
|
35
|
+
async def send_email(
|
|
36
|
+
self, to_address: str, subject: str, html_body: str, text_body: str
|
|
37
|
+
):
|
|
38
|
+
"""Send email using Amazon SES"""
|
|
39
|
+
response = self.ses_client.send_email(
|
|
40
|
+
Source=self.from_email,
|
|
41
|
+
Destination={"ToAddresses": [to_address]},
|
|
42
|
+
Message={
|
|
43
|
+
"Subject": {"Data": subject, "Charset": "UTF-8"},
|
|
44
|
+
"Body": {
|
|
45
|
+
"Html": {"Data": html_body, "Charset": "UTF-8"},
|
|
46
|
+
"Text": {"Data": text_body, "Charset": "UTF-8"},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
return response
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
from typing import AsyncGenerator
|
|
3
|
+
|
|
4
|
+
from vibetuner.context import Context, ctx
|
|
5
|
+
from vibetuner.logging import logger
|
|
6
|
+
from vibetuner.mongo import init_models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@asynccontextmanager
|
|
10
|
+
async def base_lifespan() -> AsyncGenerator[Context, None]:
|
|
11
|
+
logger.info("Vibetuner task worker starting")
|
|
12
|
+
|
|
13
|
+
await init_models()
|
|
14
|
+
|
|
15
|
+
yield ctx
|
|
16
|
+
|
|
17
|
+
logger.info("Vibetuner task worker stopping")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from app.tasks.lifespan import lifespan # ty: ignore
|
|
22
|
+
except ModuleNotFoundError:
|
|
23
|
+
# Silent pass for missing app.tasks.lifespan module (expected in some projects)
|
|
24
|
+
lifespan = base_lifespan
|
|
25
|
+
except ImportError as e:
|
|
26
|
+
# Log warning for any import error (including syntax errors, missing dependencies, etc.)
|
|
27
|
+
logger.warning(f"Failed to import app.tasks.lifespan: {e}. Using base lifespan.")
|
|
28
|
+
lifespan = base_lifespan
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from streaq import Worker
|
|
2
|
+
|
|
3
|
+
from vibetuner.config import settings
|
|
4
|
+
from vibetuner.tasks.lifespan import lifespan
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
worker = Worker(
|
|
8
|
+
redis_url=str(settings.redis_url),
|
|
9
|
+
queue_name=(
|
|
10
|
+
settings.project.project_slug
|
|
11
|
+
if not settings.debug
|
|
12
|
+
else f"debug-{settings.project.project_slug}"
|
|
13
|
+
),
|
|
14
|
+
lifespan=lifespan,
|
|
15
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{# djlint:off #}
|
|
2
|
+
<html>
|
|
3
|
+
<body>
|
|
4
|
+
<p>Click the link below to sign in to your account:</p>
|
|
5
|
+
<p>
|
|
6
|
+
<a href="{{ login_url }}"
|
|
7
|
+
style="background-color: #007bff;
|
|
8
|
+
color: white;
|
|
9
|
+
padding: 12px 24px;
|
|
10
|
+
text-decoration: none;
|
|
11
|
+
border-radius: 4px">Sign In</a>
|
|
12
|
+
</p>
|
|
13
|
+
<p>This link will expire in 15 minutes.</p>
|
|
14
|
+
<p>If you didn't request this, you can safely ignore this email.</p>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
17
|
+
{# djlint:on #}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<link rel="manifest" href="{{ url_for('site_webmanifest').path }}" />
|