vibetuner 2.26.9__py3-none-any.whl → 2.44.1__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.
Potentially problematic release.
This version of vibetuner might be problematic. Click here for more details.
- vibetuner/cli/__init__.py +2 -17
- vibetuner/cli/db.py +40 -0
- vibetuner/cli/run.py +126 -110
- vibetuner/cli/scaffold.py +7 -0
- vibetuner/config.py +55 -3
- vibetuner/frontend/__init__.py +1 -0
- vibetuner/frontend/lifespan.py +7 -2
- vibetuner/frontend/middleware.py +0 -25
- vibetuner/frontend/proxy.py +14 -0
- vibetuner/frontend/routes/debug.py +41 -31
- vibetuner/frontend/templates.py +81 -0
- vibetuner/mongo.py +55 -15
- vibetuner/paths.py +16 -13
- vibetuner/services/blob.py +31 -45
- vibetuner/services/email.py +14 -15
- vibetuner/services/s3_storage.py +454 -0
- vibetuner/sqlmodel.py +109 -0
- vibetuner/tasks/lifespan.py +7 -2
- vibetuner/tasks/worker.py +9 -8
- vibetuner/templates/frontend/email_sent.html.jinja +1 -1
- vibetuner/templates/frontend/index.html.jinja +2 -2
- vibetuner/templates/frontend/login.html.jinja +1 -1
- vibetuner/templates/frontend/user/edit.html.jinja +1 -1
- vibetuner/templates/frontend/user/profile.html.jinja +1 -1
- {vibetuner-2.26.9.dist-info → vibetuner-2.44.1.dist-info}/METADATA +39 -29
- {vibetuner-2.26.9.dist-info → vibetuner-2.44.1.dist-info}/RECORD +28 -24
- {vibetuner-2.26.9.dist-info → vibetuner-2.44.1.dist-info}/WHEEL +1 -1
- {vibetuner-2.26.9.dist-info → vibetuner-2.44.1.dist-info}/entry_points.txt +0 -0
|
@@ -3,13 +3,13 @@ from fastapi import (
|
|
|
3
3
|
Depends,
|
|
4
4
|
HTTPException,
|
|
5
5
|
Request,
|
|
6
|
-
Response,
|
|
7
6
|
)
|
|
8
7
|
from fastapi.responses import (
|
|
9
8
|
HTMLResponse,
|
|
10
9
|
RedirectResponse,
|
|
11
10
|
)
|
|
12
11
|
|
|
12
|
+
from vibetuner.config import settings
|
|
13
13
|
from vibetuner.context import ctx
|
|
14
14
|
from vibetuner.models import UserModel
|
|
15
15
|
from vibetuner.models.registry import get_all_models
|
|
@@ -18,32 +18,41 @@ from ..deps import MAGIC_COOKIE_NAME
|
|
|
18
18
|
from ..templates import render_template
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def check_debug_access(request: Request
|
|
21
|
+
def check_debug_access(request: Request):
|
|
22
22
|
"""Check if debug routes should be accessible."""
|
|
23
23
|
# Always allow in development mode
|
|
24
24
|
if ctx.DEBUG:
|
|
25
25
|
return True
|
|
26
26
|
|
|
27
|
-
# In production, require
|
|
28
|
-
if
|
|
29
|
-
|
|
27
|
+
# In production, require magic cookie
|
|
28
|
+
if MAGIC_COOKIE_NAME not in request.cookies:
|
|
29
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
30
|
+
if request.cookies[MAGIC_COOKIE_NAME] != "granted":
|
|
31
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
raise HTTPException(status_code=404, detail="Not found")
|
|
33
|
+
return True
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
router
|
|
36
|
+
# Unprotected router for token-based debug access
|
|
37
|
+
auth_router = APIRouter()
|
|
36
38
|
|
|
37
39
|
|
|
38
|
-
@
|
|
39
|
-
def
|
|
40
|
-
|
|
40
|
+
@auth_router.get("/_unlock-debug")
|
|
41
|
+
def unlock_debug_access(token: str | None = None):
|
|
42
|
+
"""Grant debug access by setting the magic cookie.
|
|
41
43
|
|
|
44
|
+
In DEBUG mode, no token is required.
|
|
45
|
+
In production, the token must match DEBUG_ACCESS_TOKEN.
|
|
46
|
+
If DEBUG_ACCESS_TOKEN is not configured, debug access is disabled in production.
|
|
47
|
+
"""
|
|
48
|
+
if not ctx.DEBUG:
|
|
49
|
+
# In production, validate token
|
|
50
|
+
if settings.debug_access_token is None:
|
|
51
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
52
|
+
if token is None or token != settings.debug_access_token:
|
|
53
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
42
54
|
|
|
43
|
-
|
|
44
|
-
def set_magic_cookie(response: Response):
|
|
45
|
-
"""Set the magic access cookie."""
|
|
46
|
-
response = RedirectResponse(url="/", status_code=302)
|
|
55
|
+
response = RedirectResponse(url="/debug", status_code=302)
|
|
47
56
|
response.set_cookie(
|
|
48
57
|
key=MAGIC_COOKIE_NAME,
|
|
49
58
|
value="granted",
|
|
@@ -55,14 +64,23 @@ def set_magic_cookie(response: Response):
|
|
|
55
64
|
return response
|
|
56
65
|
|
|
57
66
|
|
|
58
|
-
@
|
|
59
|
-
def
|
|
60
|
-
"""
|
|
67
|
+
@auth_router.get("/_lock-debug")
|
|
68
|
+
def lock_debug_access():
|
|
69
|
+
"""Revoke debug access by removing the magic cookie."""
|
|
61
70
|
response = RedirectResponse(url="/", status_code=302)
|
|
62
71
|
response.delete_cookie(key=MAGIC_COOKIE_NAME)
|
|
63
72
|
return response
|
|
64
73
|
|
|
65
74
|
|
|
75
|
+
# Protected router for debug endpoints requiring cookie auth
|
|
76
|
+
router = APIRouter(prefix="/debug", dependencies=[Depends(check_debug_access)])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.get("/", response_class=HTMLResponse)
|
|
80
|
+
def debug_index(request: Request):
|
|
81
|
+
return render_template("debug/index.html.jinja", request)
|
|
82
|
+
|
|
83
|
+
|
|
66
84
|
@router.get("/version", response_class=HTMLResponse)
|
|
67
85
|
def debug_version(request: Request):
|
|
68
86
|
return render_template("debug/version.html.jinja", request)
|
|
@@ -136,20 +154,11 @@ def _is_beanie_link_schema(option: dict) -> bool:
|
|
|
136
154
|
|
|
137
155
|
def _infer_link_target_from_field_name(field_name: str) -> str:
|
|
138
156
|
"""Infer the target model type from field name patterns."""
|
|
139
|
-
#
|
|
157
|
+
# Core vibetuner models
|
|
140
158
|
patterns = {
|
|
141
159
|
"oauth_accounts": "OAuthAccountModel",
|
|
142
|
-
"accounts": "AccountModel",
|
|
143
160
|
"users": "UserModel",
|
|
144
161
|
"user": "UserModel",
|
|
145
|
-
"stations": "StationModel",
|
|
146
|
-
"station": "StationModel",
|
|
147
|
-
"rundowns": "RundownModel",
|
|
148
|
-
"rundown": "RundownModel",
|
|
149
|
-
"fillers": "FillerModel",
|
|
150
|
-
"filler": "FillerModel",
|
|
151
|
-
"voices": "VoiceModel",
|
|
152
|
-
"voice": "VoiceModel",
|
|
153
162
|
"blobs": "BlobModel",
|
|
154
163
|
"blob": "BlobModel",
|
|
155
164
|
}
|
|
@@ -374,10 +383,13 @@ async def debug_users(request: Request):
|
|
|
374
383
|
)
|
|
375
384
|
|
|
376
385
|
|
|
386
|
+
# The following endpoints are restricted to DEBUG mode only (no production access).
|
|
387
|
+
# These are dangerous operations that could compromise security if allowed in production.
|
|
388
|
+
|
|
389
|
+
|
|
377
390
|
@router.post("/impersonate/{user_id}")
|
|
378
391
|
async def debug_impersonate_user(request: Request, user_id: str):
|
|
379
392
|
"""Impersonate a user by setting their ID in the session."""
|
|
380
|
-
# Double check debug mode for security
|
|
381
393
|
if not ctx.DEBUG:
|
|
382
394
|
raise HTTPException(status_code=404, detail="Not found")
|
|
383
395
|
|
|
@@ -395,7 +407,6 @@ async def debug_impersonate_user(request: Request, user_id: str):
|
|
|
395
407
|
@router.post("/stop-impersonation")
|
|
396
408
|
async def debug_stop_impersonation(request: Request):
|
|
397
409
|
"""Stop impersonating and clear user session."""
|
|
398
|
-
# Double check debug mode for security
|
|
399
410
|
if not ctx.DEBUG:
|
|
400
411
|
raise HTTPException(status_code=404, detail="Not found")
|
|
401
412
|
|
|
@@ -406,7 +417,6 @@ async def debug_stop_impersonation(request: Request):
|
|
|
406
417
|
@router.get("/clear-session")
|
|
407
418
|
async def debug_clear_session(request: Request):
|
|
408
419
|
"""Clear all session data to fix corrupted sessions."""
|
|
409
|
-
# Double check debug mode for security
|
|
410
420
|
if not ctx.DEBUG:
|
|
411
421
|
raise HTTPException(status_code=404, detail="Not found")
|
|
412
422
|
|
vibetuner/frontend/templates.py
CHANGED
|
@@ -17,9 +17,40 @@ from .hotreload import hotreload
|
|
|
17
17
|
|
|
18
18
|
__all__ = [
|
|
19
19
|
"render_static_template",
|
|
20
|
+
"render_template",
|
|
21
|
+
"render_template_string",
|
|
22
|
+
"register_filter",
|
|
20
23
|
]
|
|
21
24
|
|
|
22
25
|
|
|
26
|
+
_filter_registry: dict[str, Any] = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register_filter(name: str | None = None):
|
|
30
|
+
"""Decorator to register a custom Jinja2 filter.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
name: Optional custom name for the filter. If not provided,
|
|
34
|
+
uses the function name.
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
@register_filter()
|
|
38
|
+
def my_filter(value):
|
|
39
|
+
return value.upper()
|
|
40
|
+
|
|
41
|
+
@register_filter("custom_name")
|
|
42
|
+
def another_filter(value):
|
|
43
|
+
return value.lower()
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def decorator(func):
|
|
47
|
+
filter_name = name or func.__name__
|
|
48
|
+
_filter_registry[filter_name] = func
|
|
49
|
+
return func
|
|
50
|
+
|
|
51
|
+
return decorator
|
|
52
|
+
|
|
53
|
+
|
|
23
54
|
def timeago(dt):
|
|
24
55
|
"""Converts a datetime object to a human-readable string representing the time elapsed since the given datetime.
|
|
25
56
|
|
|
@@ -158,6 +189,38 @@ def render_template(
|
|
|
158
189
|
return templates.TemplateResponse(template, merged_ctx, **kwargs)
|
|
159
190
|
|
|
160
191
|
|
|
192
|
+
def render_template_string(
|
|
193
|
+
template: str,
|
|
194
|
+
request: Request,
|
|
195
|
+
ctx: dict[str, Any] | None = None,
|
|
196
|
+
) -> str:
|
|
197
|
+
"""Render a template to a string instead of HTMLResponse.
|
|
198
|
+
|
|
199
|
+
Useful for Server-Sent Events (SSE), AJAX responses, or any case where you need
|
|
200
|
+
the rendered HTML as a string rather than a full HTTP response.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
template: Path to template file (e.g., "admin/partials/episode.html.jinja")
|
|
204
|
+
request: FastAPI Request object
|
|
205
|
+
ctx: Optional context dictionary to pass to template
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
str: Rendered template as a string
|
|
209
|
+
|
|
210
|
+
Example:
|
|
211
|
+
html = render_template_string(
|
|
212
|
+
"admin/partials/episode_article.html.jinja",
|
|
213
|
+
request,
|
|
214
|
+
{"episode": episode}
|
|
215
|
+
)
|
|
216
|
+
"""
|
|
217
|
+
ctx = ctx or {}
|
|
218
|
+
merged_ctx = {**data_ctx.model_dump(), "request": request, **ctx}
|
|
219
|
+
|
|
220
|
+
template_obj = templates.get_template(template)
|
|
221
|
+
return template_obj.render(merged_ctx)
|
|
222
|
+
|
|
223
|
+
|
|
161
224
|
# Global Vars
|
|
162
225
|
jinja_env.globals.update({"DEBUG": data_ctx.DEBUG})
|
|
163
226
|
jinja_env.globals.update({"hotreload": hotreload})
|
|
@@ -171,4 +234,22 @@ jinja_env.filters["format_datetime"] = format_datetime
|
|
|
171
234
|
jinja_env.filters["format_duration"] = format_duration
|
|
172
235
|
jinja_env.filters["duration"] = format_duration
|
|
173
236
|
|
|
237
|
+
# Import user-defined filters to trigger registration
|
|
238
|
+
try:
|
|
239
|
+
import app.frontend.templates as _app_templates # type: ignore[import-not-found] # noqa: F401
|
|
240
|
+
except ModuleNotFoundError:
|
|
241
|
+
# Silent pass - templates module is optional
|
|
242
|
+
pass
|
|
243
|
+
except ImportError as e:
|
|
244
|
+
from vibetuner.logging import logger
|
|
245
|
+
|
|
246
|
+
logger.warning(
|
|
247
|
+
f"Failed to import app.frontend.templates: {e}. Custom filters will not be available."
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Apply all registered custom filters
|
|
251
|
+
for filter_name, filter_func in _filter_registry.items():
|
|
252
|
+
jinja_env.filters[filter_name] = filter_func
|
|
253
|
+
|
|
254
|
+
# Configure Jinja environment after all filters are registered
|
|
174
255
|
configure_jinja_env(jinja_env)
|
vibetuner/mongo.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from importlib import import_module
|
|
2
|
+
from typing import Optional
|
|
2
3
|
|
|
3
4
|
from beanie import init_beanie
|
|
5
|
+
from deprecated import deprecated
|
|
4
6
|
from pymongo import AsyncMongoClient
|
|
5
7
|
|
|
6
8
|
from vibetuner.config import settings
|
|
@@ -8,26 +10,64 @@ from vibetuner.logging import logger
|
|
|
8
10
|
from vibetuner.models.registry import get_all_models
|
|
9
11
|
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
# Global singleton, created lazily
|
|
14
|
+
mongo_client: Optional[AsyncMongoClient] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _ensure_client() -> None:
|
|
18
|
+
"""
|
|
19
|
+
Lazily create the global MongoDB client if mongodb_url is configured.
|
|
20
|
+
Safe to call many times.
|
|
21
|
+
"""
|
|
22
|
+
global mongo_client
|
|
23
|
+
|
|
24
|
+
if settings.mongodb_url is None:
|
|
25
|
+
logger.warning("MongoDB URL is not configured. Mongo engine disabled.")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
if mongo_client is None:
|
|
29
|
+
mongo_client = AsyncMongoClient(
|
|
30
|
+
host=str(settings.mongodb_url),
|
|
31
|
+
compressors=["zstd"],
|
|
32
|
+
)
|
|
33
|
+
logger.debug("MongoDB client created.")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def init_mongodb() -> None:
|
|
37
|
+
"""Initialize MongoDB and register Beanie models."""
|
|
38
|
+
_ensure_client()
|
|
39
|
+
|
|
40
|
+
if mongo_client is None:
|
|
41
|
+
# Nothing to do; URL missing
|
|
42
|
+
return
|
|
13
43
|
|
|
14
|
-
#
|
|
44
|
+
# Import user models so they register themselves
|
|
15
45
|
try:
|
|
16
46
|
import_module("app.models")
|
|
17
47
|
except ModuleNotFoundError:
|
|
18
|
-
|
|
19
|
-
pass
|
|
48
|
+
logger.debug("app.models not found; skipping user model import.")
|
|
20
49
|
except ImportError as e:
|
|
21
|
-
|
|
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
|
-
)
|
|
50
|
+
logger.warning(f"Failed to import app.models: {e}. User models may be missing.")
|
|
30
51
|
|
|
31
52
|
await init_beanie(
|
|
32
|
-
database=
|
|
53
|
+
database=mongo_client[settings.mongo_dbname],
|
|
54
|
+
document_models=get_all_models(),
|
|
33
55
|
)
|
|
56
|
+
logger.info("MongoDB + Beanie initialized successfully.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def teardown_mongodb() -> None:
|
|
60
|
+
"""Dispose the MongoDB client."""
|
|
61
|
+
global mongo_client
|
|
62
|
+
|
|
63
|
+
if mongo_client is not None:
|
|
64
|
+
await mongo_client.close()
|
|
65
|
+
mongo_client = None
|
|
66
|
+
logger.info("MongoDB client closed.")
|
|
67
|
+
else:
|
|
68
|
+
logger.debug("MongoDB client was never initialized; nothing to tear down.")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@deprecated(reason="Use init_mongodb() instead")
|
|
72
|
+
async def init_models() -> None:
|
|
73
|
+
await init_mongodb()
|
vibetuner/paths.py
CHANGED
|
@@ -33,24 +33,27 @@ def create_core_templates_symlink(target: Path) -> None:
|
|
|
33
33
|
logger.error(f"Cannot create symlink: {e}")
|
|
34
34
|
return
|
|
35
35
|
|
|
36
|
-
# Case 1: target
|
|
36
|
+
# Case 1: target is already a symlink → check if it needs updating
|
|
37
|
+
if target.is_symlink():
|
|
38
|
+
if target.resolve() != source:
|
|
39
|
+
target.unlink()
|
|
40
|
+
target.symlink_to(source, target_is_directory=True)
|
|
41
|
+
logger.info(f"Updated symlink '{target}' → '{source}'")
|
|
42
|
+
else:
|
|
43
|
+
logger.debug(f"Symlink '{target}' already points to '{source}'")
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
# Case 2: target does not exist → create symlink
|
|
37
47
|
if not target.exists():
|
|
38
48
|
target.symlink_to(source, target_is_directory=True)
|
|
39
49
|
logger.info(f"Created symlink '{target}' → '{source}'")
|
|
40
50
|
return
|
|
41
51
|
|
|
42
|
-
# Case
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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}'")
|
|
52
|
+
# Case 3: exists but is not a symlink → error
|
|
53
|
+
logger.error(f"Cannot create symlink: '{target}' exists and is not a symlink.")
|
|
54
|
+
raise FileExistsError(
|
|
55
|
+
f"Cannot create symlink: '{target}' exists and is not a symlink."
|
|
56
|
+
)
|
|
54
57
|
|
|
55
58
|
|
|
56
59
|
# Package templates always available
|
vibetuner/services/blob.py
CHANGED
|
@@ -6,18 +6,13 @@ To extend blob functionality, create wrapper services in the parent services dir
|
|
|
6
6
|
|
|
7
7
|
import mimetypes
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Literal
|
|
10
9
|
|
|
11
10
|
import aioboto3
|
|
12
|
-
from aiobotocore.config import AioConfig
|
|
13
11
|
|
|
14
12
|
from vibetuner.config import settings
|
|
15
13
|
from vibetuner.models import BlobModel
|
|
16
14
|
from vibetuner.models.blob import BlobStatus
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
S3_SERVICE_NAME: Literal["s3"] = "s3"
|
|
20
|
-
DEFAULT_CONTENT_TYPE: str = "application/octet-stream"
|
|
15
|
+
from vibetuner.services.s3_storage import DEFAULT_CONTENT_TYPE, S3StorageService
|
|
21
16
|
|
|
22
17
|
|
|
23
18
|
class BlobService:
|
|
@@ -34,25 +29,22 @@ class BlobService:
|
|
|
34
29
|
raise ValueError(
|
|
35
30
|
"R2 bucket endpoint URL, access key, and secret key must be set in settings."
|
|
36
31
|
)
|
|
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
32
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
33
|
+
bucket = default_bucket or settings.r2_default_bucket_name
|
|
34
|
+
if bucket is None:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
"Default bucket name must be provided either in settings or as an argument."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
self.storage = S3StorageService(
|
|
40
|
+
endpoint_url=str(settings.r2_bucket_endpoint_url),
|
|
41
|
+
access_key=settings.r2_access_key.get_secret_value(),
|
|
42
|
+
secret_key=settings.r2_secret_key.get_secret_value(),
|
|
43
|
+
region=settings.r2_default_region,
|
|
44
|
+
default_bucket=bucket,
|
|
45
|
+
session=session,
|
|
46
|
+
)
|
|
47
|
+
self.default_bucket = bucket
|
|
56
48
|
|
|
57
49
|
async def put_object(
|
|
58
50
|
self,
|
|
@@ -80,17 +72,12 @@ class BlobService:
|
|
|
80
72
|
raise ValueError("Blob ID must be set before uploading to R2.")
|
|
81
73
|
|
|
82
74
|
try:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
Bucket=bucket,
|
|
90
|
-
Key=blob.full_path,
|
|
91
|
-
Body=body,
|
|
92
|
-
ContentType=content_type,
|
|
93
|
-
)
|
|
75
|
+
await self.storage.put_object(
|
|
76
|
+
key=blob.full_path,
|
|
77
|
+
body=body,
|
|
78
|
+
content_type=content_type,
|
|
79
|
+
bucket=bucket,
|
|
80
|
+
)
|
|
94
81
|
blob.status = BlobStatus.UPLOADED
|
|
95
82
|
except Exception:
|
|
96
83
|
blob.status = BlobStatus.ERROR
|
|
@@ -144,16 +131,10 @@ class BlobService:
|
|
|
144
131
|
if not blob:
|
|
145
132
|
raise ValueError(f"Blob not found: {key}")
|
|
146
133
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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()
|
|
134
|
+
return await self.storage.get_object(
|
|
135
|
+
key=blob.full_path,
|
|
136
|
+
bucket=blob.bucket,
|
|
137
|
+
)
|
|
157
138
|
|
|
158
139
|
async def delete_object(self, key: str) -> None:
|
|
159
140
|
"""Delete an object from the R2 bucket"""
|
|
@@ -172,4 +153,9 @@ class BlobService:
|
|
|
172
153
|
if not blob:
|
|
173
154
|
return False
|
|
174
155
|
|
|
156
|
+
if check_bucket:
|
|
157
|
+
return await self.storage.object_exists(
|
|
158
|
+
key=blob.full_path, bucket=blob.bucket
|
|
159
|
+
)
|
|
160
|
+
|
|
175
161
|
return True
|
vibetuner/services/email.py
CHANGED
|
@@ -6,7 +6,7 @@ To extend email functionality, create wrapper services in the parent services di
|
|
|
6
6
|
|
|
7
7
|
from typing import Literal
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import aioboto3
|
|
10
10
|
|
|
11
11
|
from vibetuner.config import settings
|
|
12
12
|
|
|
@@ -17,11 +17,9 @@ SES_SERVICE_NAME: Literal["ses"] = "ses"
|
|
|
17
17
|
class SESEmailService:
|
|
18
18
|
def __init__(
|
|
19
19
|
self,
|
|
20
|
-
ses_client=None,
|
|
21
20
|
from_email: str | None = None,
|
|
22
21
|
) -> None:
|
|
23
|
-
self.
|
|
24
|
-
service_name=SES_SERVICE_NAME,
|
|
22
|
+
self.session = aioboto3.Session(
|
|
25
23
|
region_name=settings.project.aws_default_region,
|
|
26
24
|
aws_access_key_id=settings.aws_access_key_id.get_secret_value()
|
|
27
25
|
if settings.aws_access_key_id
|
|
@@ -36,15 +34,16 @@ class SESEmailService:
|
|
|
36
34
|
self, to_address: str, subject: str, html_body: str, text_body: str
|
|
37
35
|
):
|
|
38
36
|
"""Send email using Amazon SES"""
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
|
|
37
|
+
async with self.session.client(SES_SERVICE_NAME) as ses_client:
|
|
38
|
+
response = await ses_client.send_email(
|
|
39
|
+
Source=self.from_email,
|
|
40
|
+
Destination={"ToAddresses": [to_address]},
|
|
41
|
+
Message={
|
|
42
|
+
"Subject": {"Data": subject, "Charset": "UTF-8"},
|
|
43
|
+
"Body": {
|
|
44
|
+
"Html": {"Data": html_body, "Charset": "UTF-8"},
|
|
45
|
+
"Text": {"Data": text_body, "Charset": "UTF-8"},
|
|
46
|
+
},
|
|
47
47
|
},
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return response
|
|
48
|
+
)
|
|
49
|
+
return response
|