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.
Files changed (71) hide show
  1. vibetuner/__init__.py +2 -0
  2. vibetuner/__main__.py +4 -0
  3. vibetuner/cli/__init__.py +141 -0
  4. vibetuner/cli/run.py +160 -0
  5. vibetuner/cli/scaffold.py +187 -0
  6. vibetuner/config.py +143 -0
  7. vibetuner/context.py +28 -0
  8. vibetuner/frontend/__init__.py +107 -0
  9. vibetuner/frontend/deps.py +41 -0
  10. vibetuner/frontend/email.py +45 -0
  11. vibetuner/frontend/hotreload.py +13 -0
  12. vibetuner/frontend/lifespan.py +37 -0
  13. vibetuner/frontend/middleware.py +151 -0
  14. vibetuner/frontend/oauth.py +196 -0
  15. vibetuner/frontend/routes/__init__.py +12 -0
  16. vibetuner/frontend/routes/auth.py +156 -0
  17. vibetuner/frontend/routes/debug.py +414 -0
  18. vibetuner/frontend/routes/health.py +37 -0
  19. vibetuner/frontend/routes/language.py +43 -0
  20. vibetuner/frontend/routes/meta.py +55 -0
  21. vibetuner/frontend/routes/user.py +94 -0
  22. vibetuner/frontend/templates.py +176 -0
  23. vibetuner/logging.py +87 -0
  24. vibetuner/models/__init__.py +14 -0
  25. vibetuner/models/blob.py +89 -0
  26. vibetuner/models/email_verification.py +84 -0
  27. vibetuner/models/mixins.py +76 -0
  28. vibetuner/models/oauth.py +57 -0
  29. vibetuner/models/registry.py +15 -0
  30. vibetuner/models/types.py +16 -0
  31. vibetuner/models/user.py +91 -0
  32. vibetuner/mongo.py +33 -0
  33. vibetuner/paths.py +250 -0
  34. vibetuner/services/__init__.py +0 -0
  35. vibetuner/services/blob.py +175 -0
  36. vibetuner/services/email.py +50 -0
  37. vibetuner/tasks/__init__.py +0 -0
  38. vibetuner/tasks/lifespan.py +28 -0
  39. vibetuner/tasks/worker.py +15 -0
  40. vibetuner/templates/email/magic_link.html.jinja +17 -0
  41. vibetuner/templates/email/magic_link.txt.jinja +5 -0
  42. vibetuner/templates/frontend/base/favicons.html.jinja +1 -0
  43. vibetuner/templates/frontend/base/footer.html.jinja +3 -0
  44. vibetuner/templates/frontend/base/header.html.jinja +0 -0
  45. vibetuner/templates/frontend/base/opengraph.html.jinja +7 -0
  46. vibetuner/templates/frontend/base/skeleton.html.jinja +45 -0
  47. vibetuner/templates/frontend/debug/collections.html.jinja +105 -0
  48. vibetuner/templates/frontend/debug/components/debug_nav.html.jinja +55 -0
  49. vibetuner/templates/frontend/debug/index.html.jinja +85 -0
  50. vibetuner/templates/frontend/debug/info.html.jinja +258 -0
  51. vibetuner/templates/frontend/debug/users.html.jinja +139 -0
  52. vibetuner/templates/frontend/debug/version.html.jinja +55 -0
  53. vibetuner/templates/frontend/email/magic_link.txt.jinja +5 -0
  54. vibetuner/templates/frontend/email_sent.html.jinja +83 -0
  55. vibetuner/templates/frontend/index.html.jinja +20 -0
  56. vibetuner/templates/frontend/lang/select.html.jinja +4 -0
  57. vibetuner/templates/frontend/login.html.jinja +89 -0
  58. vibetuner/templates/frontend/meta/browserconfig.xml.jinja +10 -0
  59. vibetuner/templates/frontend/meta/robots.txt.jinja +3 -0
  60. vibetuner/templates/frontend/meta/site.webmanifest.jinja +7 -0
  61. vibetuner/templates/frontend/meta/sitemap.xml.jinja +6 -0
  62. vibetuner/templates/frontend/user/edit.html.jinja +86 -0
  63. vibetuner/templates/frontend/user/profile.html.jinja +157 -0
  64. vibetuner/templates/markdown/.placeholder +0 -0
  65. vibetuner/templates.py +146 -0
  66. vibetuner/time.py +57 -0
  67. vibetuner/versioning.py +12 -0
  68. vibetuner-2.26.6.dist-info/METADATA +241 -0
  69. vibetuner-2.26.6.dist-info/RECORD +71 -0
  70. vibetuner-2.26.6.dist-info/WHEEL +4 -0
  71. 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
@@ -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,5 @@
1
+ Sign in to {{ project_name }}
2
+ Copy and paste this link into your browser to sign in:
3
+ {{ login_url }}
4
+ This link will expire in 15 minutes.
5
+ If you didn't request this, you can safely ignore this email.
@@ -0,0 +1 @@
1
+ <link rel="manifest" href="{{ url_for('site_webmanifest').path }}" />