vibetuner 2.6.1__py3-none-any.whl → 2.7.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.
Potentially problematic release.
This version of vibetuner might be problematic. Click here for more details.
- vibetuner/__init__.py +2 -0
- vibetuner/__main__.py +4 -0
- vibetuner/cli/__init__.py +68 -0
- vibetuner/cli/run.py +161 -0
- vibetuner/config.py +128 -0
- vibetuner/context.py +25 -0
- vibetuner/frontend/AGENTS.md +113 -0
- vibetuner/frontend/CLAUDE.md +113 -0
- vibetuner/frontend/__init__.py +94 -0
- vibetuner/frontend/context.py +10 -0
- vibetuner/frontend/deps.py +41 -0
- vibetuner/frontend/email.py +45 -0
- vibetuner/frontend/hotreload.py +13 -0
- vibetuner/frontend/lifespan.py +26 -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 +150 -0
- vibetuner/frontend/routes/debug.py +414 -0
- vibetuner/frontend/routes/health.py +33 -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/AGENTS.md +165 -0
- vibetuner/models/CLAUDE.md +165 -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 +18 -0
- vibetuner/paths.py +112 -0
- vibetuner/services/AGENTS.md +104 -0
- vibetuner/services/CLAUDE.md +104 -0
- vibetuner/services/__init__.py +0 -0
- vibetuner/services/blob.py +175 -0
- vibetuner/services/email.py +50 -0
- vibetuner/tasks/AGENTS.md +98 -0
- vibetuner/tasks/CLAUDE.md +98 -0
- vibetuner/tasks/__init__.py +2 -0
- vibetuner/tasks/context.py +34 -0
- vibetuner/tasks/worker.py +18 -0
- vibetuner/templates/email/AGENTS.md +48 -0
- vibetuner/templates/email/CLAUDE.md +48 -0
- vibetuner/templates/email/default/magic_link.html.jinja +16 -0
- vibetuner/templates/email/default/magic_link.txt.jinja +5 -0
- vibetuner/templates/frontend/AGENTS.md +74 -0
- vibetuner/templates/frontend/CLAUDE.md +74 -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 +42 -0
- vibetuner/templates/frontend/debug/collections.html.jinja +103 -0
- vibetuner/templates/frontend/debug/components/debug_nav.html.jinja +55 -0
- vibetuner/templates/frontend/debug/index.html.jinja +83 -0
- vibetuner/templates/frontend/debug/info.html.jinja +256 -0
- vibetuner/templates/frontend/debug/users.html.jinja +137 -0
- vibetuner/templates/frontend/debug/version.html.jinja +53 -0
- vibetuner/templates/frontend/email/magic_link.txt.jinja +5 -0
- vibetuner/templates/frontend/email_sent.html.jinja +82 -0
- vibetuner/templates/frontend/index.html.jinja +19 -0
- vibetuner/templates/frontend/lang/select.html.jinja +4 -0
- vibetuner/templates/frontend/login.html.jinja +84 -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 +85 -0
- vibetuner/templates/frontend/user/profile.html.jinja +156 -0
- vibetuner/templates/markdown/.placeholder +0 -0
- vibetuner/templates/markdown/AGENTS.md +29 -0
- vibetuner/templates/markdown/CLAUDE.md +29 -0
- vibetuner/templates.py +152 -0
- vibetuner/time.py +57 -0
- vibetuner/versioning.py +8 -0
- {vibetuner-2.6.1.dist-info → vibetuner-2.7.0.dist-info}/METADATA +2 -1
- vibetuner-2.7.0.dist-info/RECORD +84 -0
- vibetuner-2.6.1.dist-info/RECORD +0 -4
- {vibetuner-2.6.1.dist-info → vibetuner-2.7.0.dist-info}/WHEEL +0 -0
vibetuner/models/blob.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Blob storage model for file uploads and management.
|
|
2
|
+
|
|
3
|
+
WARNING: This is a scaffolding-managed file. DO NOT MODIFY directly.
|
|
4
|
+
Manages file metadata for S3 or local storage backends.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Self
|
|
10
|
+
|
|
11
|
+
from beanie import Document
|
|
12
|
+
from pydantic import Field
|
|
13
|
+
|
|
14
|
+
from vibetuner.models.registry import register_model
|
|
15
|
+
|
|
16
|
+
from .mixins import TimeStampMixin
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BlobStatus(StrEnum):
|
|
20
|
+
PENDING = "pending"
|
|
21
|
+
UPLOADED = "uploaded"
|
|
22
|
+
DELETED = "deleted"
|
|
23
|
+
ERROR = "error"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@register_model
|
|
27
|
+
class BlobModel(Document, TimeStampMixin):
|
|
28
|
+
status: BlobStatus = Field(
|
|
29
|
+
default=BlobStatus.PENDING,
|
|
30
|
+
description="Status of the blob indicating if it is pending, uploaded, or deleted",
|
|
31
|
+
)
|
|
32
|
+
bucket: str = Field(
|
|
33
|
+
...,
|
|
34
|
+
description="Storage bucket name where the object is stored",
|
|
35
|
+
)
|
|
36
|
+
content_type: str = Field(
|
|
37
|
+
...,
|
|
38
|
+
description="MIME type of the object (e.g., image/png, application/pdf)",
|
|
39
|
+
)
|
|
40
|
+
original_filename: str | None = Field(
|
|
41
|
+
default=None,
|
|
42
|
+
description="Original name of the file as uploaded by the user (if applicable)",
|
|
43
|
+
)
|
|
44
|
+
namespace: str | None = Field(
|
|
45
|
+
default=None,
|
|
46
|
+
description="Namespaces (prefixes) for the object, used for organization (optional)",
|
|
47
|
+
)
|
|
48
|
+
checksum: str = Field(
|
|
49
|
+
...,
|
|
50
|
+
description="SHA256 hash of the object for integrity verification",
|
|
51
|
+
)
|
|
52
|
+
size: int = Field(..., description="Size of the object in bytes")
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def full_path(self) -> str:
|
|
56
|
+
"""Get the full path of the blob in the bucket."""
|
|
57
|
+
if self.namespace:
|
|
58
|
+
return f"{self.namespace}/{self.id}"
|
|
59
|
+
else:
|
|
60
|
+
return f"{self.id}"
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_bytes(
|
|
64
|
+
cls,
|
|
65
|
+
body: bytes,
|
|
66
|
+
content_type: str,
|
|
67
|
+
bucket: str,
|
|
68
|
+
namespace: str | None = None,
|
|
69
|
+
original_filename: str | None = None,
|
|
70
|
+
) -> Self:
|
|
71
|
+
"""Create a BlobModel instance from raw bytes and metadata."""
|
|
72
|
+
return cls(
|
|
73
|
+
original_filename=original_filename,
|
|
74
|
+
content_type=content_type,
|
|
75
|
+
bucket=bucket,
|
|
76
|
+
namespace=namespace,
|
|
77
|
+
checksum=cls.calculate_checksum(body),
|
|
78
|
+
size=len(body),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def calculate_checksum(body: bytes) -> str:
|
|
83
|
+
"""Calculate SHA256 checksum of the given bytes."""
|
|
84
|
+
|
|
85
|
+
return hashlib.sha256(body).hexdigest()
|
|
86
|
+
|
|
87
|
+
class Settings:
|
|
88
|
+
name = "blobs"
|
|
89
|
+
keep_nulls = False
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Email verification model for magic link authentication.
|
|
2
|
+
|
|
3
|
+
WARNING: This is a scaffolding-managed file. DO NOT MODIFY directly.
|
|
4
|
+
Handles passwordless authentication via email verification tokens.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import secrets
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from typing import Optional, Self
|
|
10
|
+
|
|
11
|
+
from beanie import Document
|
|
12
|
+
from beanie.operators import Eq, Set
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
|
|
15
|
+
from vibetuner.models.registry import register_model
|
|
16
|
+
from vibetuner.time import now
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Email verification token model
|
|
20
|
+
@register_model
|
|
21
|
+
class EmailVerificationTokenModel(Document):
|
|
22
|
+
email: str = Field(
|
|
23
|
+
...,
|
|
24
|
+
description="Email address requesting verification",
|
|
25
|
+
)
|
|
26
|
+
token: str = Field(
|
|
27
|
+
...,
|
|
28
|
+
description="Secure random token for email verification",
|
|
29
|
+
)
|
|
30
|
+
expires_at: datetime = Field(
|
|
31
|
+
...,
|
|
32
|
+
description="Token expiration timestamp",
|
|
33
|
+
)
|
|
34
|
+
used: bool = Field(
|
|
35
|
+
default=False,
|
|
36
|
+
description="Whether the token has been consumed",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
class Settings:
|
|
40
|
+
name = "email_verification_tokens"
|
|
41
|
+
indexes = [
|
|
42
|
+
[("token", 1)],
|
|
43
|
+
[("email", 1)],
|
|
44
|
+
[("expires_at", 1)],
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
async def create_token(cls, email: str, expires_minutes: int = 15) -> Self:
|
|
49
|
+
"""Create a new verification token for email login"""
|
|
50
|
+
token = secrets.token_urlsafe(32)
|
|
51
|
+
expires_at = now() + timedelta(minutes=expires_minutes)
|
|
52
|
+
|
|
53
|
+
# Invalidate any existing tokens for this email
|
|
54
|
+
await cls.find(Eq(cls.email, email)).update_many(Set({cls.used: True}))
|
|
55
|
+
|
|
56
|
+
verification_token = cls(
|
|
57
|
+
email=email, token=token, expires_at=expires_at, used=False
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return await verification_token.insert()
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
async def verify_token(cls, token: str) -> Optional[Self]:
|
|
64
|
+
"""Verify and consume a token"""
|
|
65
|
+
verification_token: Optional[Self] = await cls.find_one(
|
|
66
|
+
Eq(cls.token, token), Eq(cls.used, False)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if not verification_token:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
# Ensure expires_at is timezone-aware for comparison
|
|
73
|
+
expires_at = verification_token.expires_at
|
|
74
|
+
if expires_at.tzinfo is None:
|
|
75
|
+
from datetime import timezone
|
|
76
|
+
|
|
77
|
+
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
78
|
+
|
|
79
|
+
if expires_at < now():
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
# Mark token as used
|
|
83
|
+
verification_token.used = True
|
|
84
|
+
return await verification_token.save()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Reusable model mixins for common functionality.
|
|
2
|
+
|
|
3
|
+
WARNING: This is a scaffolding-managed file. DO NOT MODIFY directly.
|
|
4
|
+
Provides timestamp tracking and other common model behaviors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Self
|
|
10
|
+
|
|
11
|
+
from beanie import Insert, Replace, Save, SaveChanges, Update, before_event
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from vibetuner.time import Unit, now
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Since(StrEnum):
|
|
18
|
+
"""Reference moment for age calculations."""
|
|
19
|
+
|
|
20
|
+
CREATION = "creation"
|
|
21
|
+
UPDATE = "update"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ────────────────────────────────────────────────────────────────
|
|
25
|
+
# Drop-in mixin
|
|
26
|
+
# ────────────────────────────────────────────────────────────────
|
|
27
|
+
class TimeStampMixin(BaseModel):
|
|
28
|
+
"""
|
|
29
|
+
✦ Automatic UTC timestamps on insert/update
|
|
30
|
+
✦ Typed helpers for age checks
|
|
31
|
+
|
|
32
|
+
doc.age() → timedelta
|
|
33
|
+
doc.age_in(Unit.HOURS) → float
|
|
34
|
+
doc.is_older_than(td, since=…) → bool
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
db_insert_dt: datetime = Field(
|
|
38
|
+
default_factory=lambda: now(),
|
|
39
|
+
description="Timestamp when the document was first created and inserted into the database (UTC)",
|
|
40
|
+
)
|
|
41
|
+
db_update_dt: datetime = Field(
|
|
42
|
+
default_factory=lambda: now(),
|
|
43
|
+
description="Timestamp when the document was last modified or updated (UTC)",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# ── Beanie hooks ────────────────────────────────────────────
|
|
47
|
+
@before_event(Insert)
|
|
48
|
+
def _touch_on_insert(self) -> None:
|
|
49
|
+
_now = now()
|
|
50
|
+
self.db_insert_dt = _now
|
|
51
|
+
self.db_update_dt = _now
|
|
52
|
+
|
|
53
|
+
@before_event(Update, SaveChanges, Save, Replace)
|
|
54
|
+
def _touch_on_update(self) -> None:
|
|
55
|
+
self.db_update_dt = now()
|
|
56
|
+
|
|
57
|
+
# ── Public helpers ──────────────────────────────────────────
|
|
58
|
+
def age(self, *, since: Since = Since.CREATION) -> timedelta:
|
|
59
|
+
"""Timedelta since *creation* or last *update* (default: creation)."""
|
|
60
|
+
ref = self.db_update_dt if since is Since.UPDATE else self.db_insert_dt
|
|
61
|
+
return now() - ref
|
|
62
|
+
|
|
63
|
+
def age_in(
|
|
64
|
+
self, unit: Unit = Unit.SECONDS, *, since: Since = Since.CREATION
|
|
65
|
+
) -> float:
|
|
66
|
+
"""Age expressed as a float in the requested `unit`."""
|
|
67
|
+
return self.age(since=since).total_seconds() / unit.factor
|
|
68
|
+
|
|
69
|
+
def is_older_than(self, delta: timedelta, *, since: Since = Since.CREATION) -> bool:
|
|
70
|
+
"""True iff the document’s age ≥ `delta`."""
|
|
71
|
+
return self.age(since=since) >= delta
|
|
72
|
+
|
|
73
|
+
def touch(self) -> Self:
|
|
74
|
+
"""Manually bump `db_update_dt` and return `self` (chain-friendly)."""
|
|
75
|
+
self.db_update_dt = now()
|
|
76
|
+
return self
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from typing import Self
|
|
2
|
+
|
|
3
|
+
from beanie import Document
|
|
4
|
+
from beanie.operators import Eq
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from vibetuner.models.registry import register_model
|
|
8
|
+
|
|
9
|
+
from .mixins import TimeStampMixin
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OauthProviderModel(BaseModel):
|
|
13
|
+
identifier: str
|
|
14
|
+
params: dict[str, str] = {}
|
|
15
|
+
client_kwargs: dict[str, str]
|
|
16
|
+
config: dict[str, str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@register_model
|
|
20
|
+
class OAuthAccountModel(Document, TimeStampMixin):
|
|
21
|
+
provider: str = Field(
|
|
22
|
+
...,
|
|
23
|
+
description="OAuth provider name (google, github, twitter, etc.)",
|
|
24
|
+
)
|
|
25
|
+
provider_user_id: str = Field(
|
|
26
|
+
...,
|
|
27
|
+
description="Unique user identifier from the OAuth provider",
|
|
28
|
+
)
|
|
29
|
+
email: str | None = Field(
|
|
30
|
+
default=None,
|
|
31
|
+
description="Email address retrieved from OAuth provider profile",
|
|
32
|
+
)
|
|
33
|
+
name: str | None = Field(
|
|
34
|
+
default=None,
|
|
35
|
+
description="Full display name retrieved from OAuth provider profile",
|
|
36
|
+
)
|
|
37
|
+
picture: str | None = Field(
|
|
38
|
+
default=None,
|
|
39
|
+
description="Profile picture URL retrieved from OAuth provider",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
class Settings:
|
|
43
|
+
name = "oauth_accounts"
|
|
44
|
+
indexes = [
|
|
45
|
+
[("provider", 1), ("provider_user_id", 1)],
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
async def get_by_provider_and_id(
|
|
50
|
+
cls,
|
|
51
|
+
provider: str,
|
|
52
|
+
provider_user_id: str,
|
|
53
|
+
) -> Self | None:
|
|
54
|
+
return await cls.find_one(
|
|
55
|
+
Eq(cls.provider, provider),
|
|
56
|
+
Eq(cls.provider_user_id, provider_user_id),
|
|
57
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from beanie import Document, View
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
_MODEL_REGISTRY: list[type[Document] | type[View]] = []
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def register_model(cls):
|
|
8
|
+
"""Decorator to register a model"""
|
|
9
|
+
_MODEL_REGISTRY.append(cls)
|
|
10
|
+
return cls
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_all_models():
|
|
14
|
+
"""Get all registered models (call at startup)"""
|
|
15
|
+
return _MODEL_REGISTRY
|
|
@@ -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,18 @@
|
|
|
1
|
+
from beanie import init_beanie
|
|
2
|
+
from pymongo import AsyncMongoClient
|
|
3
|
+
|
|
4
|
+
from vibetuner.config import settings
|
|
5
|
+
from vibetuner.models.registry import get_all_models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def init_models() -> None:
|
|
9
|
+
"""Initialize MongoDB connection and register all Beanie models."""
|
|
10
|
+
|
|
11
|
+
client: AsyncMongoClient = AsyncMongoClient(
|
|
12
|
+
host=str(settings.project.mongodb_url),
|
|
13
|
+
compressors=["zstd"],
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
await init_beanie(
|
|
17
|
+
database=client[settings.mongo_dbname], document_models=get_all_models()
|
|
18
|
+
)
|
vibetuner/paths.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from importlib.resources import files
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
# Package-relative paths (for bundled templates in the vibetuner package)
|
|
5
|
+
_package_files = files("vibetuner")
|
|
6
|
+
_package_templates_traversable = _package_files / "templates"
|
|
7
|
+
|
|
8
|
+
# Convert to Path when actually used (handles both filesystem and zip-based packages)
|
|
9
|
+
def _get_package_templates_path() -> Path:
|
|
10
|
+
"""Get package templates path, works for both installed and editable installs."""
|
|
11
|
+
# For most cases, we can convert directly to Path
|
|
12
|
+
# For zip files, importlib.resources handles extraction automatically
|
|
13
|
+
try:
|
|
14
|
+
return Path(str(_package_templates_traversable))
|
|
15
|
+
except (TypeError, ValueError):
|
|
16
|
+
# If we can't convert to Path, we're in a zip or similar
|
|
17
|
+
# In this case, we'll need to use as_file() context manager when accessing
|
|
18
|
+
# For now, raise an error - we can enhance this later if needed
|
|
19
|
+
raise RuntimeError(
|
|
20
|
+
"Package templates are in a non-filesystem location. "
|
|
21
|
+
"This is not yet supported."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
package_templates = _get_package_templates_path()
|
|
26
|
+
|
|
27
|
+
# Project root (set at runtime by the application using vibetuner)
|
|
28
|
+
# When None, only package templates are available
|
|
29
|
+
root: Path | None = None
|
|
30
|
+
fallback_path = "defaults"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def set_project_root(project_root: Path) -> None:
|
|
34
|
+
"""Set the project root directory for the application using vibetuner.
|
|
35
|
+
|
|
36
|
+
This enables access to project-specific templates, assets, and locales.
|
|
37
|
+
Must be called before accessing project-specific paths.
|
|
38
|
+
"""
|
|
39
|
+
global root, templates, app_templates, locales, config_vars
|
|
40
|
+
global assets, statics, css, js, favicons, img
|
|
41
|
+
global frontend_templates, email_templates, markdown_templates
|
|
42
|
+
|
|
43
|
+
root = project_root
|
|
44
|
+
|
|
45
|
+
# Update project-specific paths
|
|
46
|
+
templates = root / "templates"
|
|
47
|
+
app_templates = templates # Deprecated: projects now use templates/ directly
|
|
48
|
+
locales = root / "locales"
|
|
49
|
+
config_vars = root / ".copier-answers.yml"
|
|
50
|
+
|
|
51
|
+
# Update asset paths
|
|
52
|
+
assets = root / "assets"
|
|
53
|
+
statics = assets / "statics"
|
|
54
|
+
css = statics / "css"
|
|
55
|
+
js = statics / "js"
|
|
56
|
+
favicons = statics / "favicons"
|
|
57
|
+
img = statics / "img"
|
|
58
|
+
|
|
59
|
+
# Update template lists to include project overrides
|
|
60
|
+
frontend_templates = [templates / "frontend", package_templates / "frontend"]
|
|
61
|
+
email_templates = [templates / "email", package_templates / "email"]
|
|
62
|
+
markdown_templates = [templates / "markdown", package_templates / "markdown"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def to_template_path_list(path: Path) -> list[Path]:
|
|
66
|
+
return [
|
|
67
|
+
path,
|
|
68
|
+
path / fallback_path,
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def fallback_static_default(static_type: str, file_name: str) -> Path:
|
|
73
|
+
"""Return a fallback path for a file."""
|
|
74
|
+
if root is None:
|
|
75
|
+
raise RuntimeError(
|
|
76
|
+
"Project root not set. Call set_project_root() before accessing assets."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
paths_to_check = [
|
|
80
|
+
statics / static_type / file_name,
|
|
81
|
+
statics / fallback_path / static_type / file_name,
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
for path in paths_to_check:
|
|
85
|
+
if path.exists():
|
|
86
|
+
return path
|
|
87
|
+
|
|
88
|
+
raise FileNotFoundError(
|
|
89
|
+
f"Could not find {file_name} in any of the fallback paths: {paths_to_check}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Core templates point to package (always available)
|
|
94
|
+
core_templates = package_templates
|
|
95
|
+
|
|
96
|
+
# Template paths - initially only package templates, updated when set_project_root() is called
|
|
97
|
+
frontend_templates = [package_templates / "frontend"]
|
|
98
|
+
email_templates = [package_templates / "email"]
|
|
99
|
+
markdown_templates = [package_templates / "markdown"]
|
|
100
|
+
|
|
101
|
+
# Project-specific paths - will be None until set_project_root() is called
|
|
102
|
+
# These get updated by set_project_root()
|
|
103
|
+
templates: Path | None = None
|
|
104
|
+
app_templates: Path | None = None
|
|
105
|
+
locales: Path | None = None
|
|
106
|
+
config_vars: Path | None = None
|
|
107
|
+
assets: Path | None = None
|
|
108
|
+
statics: Path | None = None
|
|
109
|
+
css: Path | None = None
|
|
110
|
+
js: Path | None = None
|
|
111
|
+
favicons: Path | None = None
|
|
112
|
+
img: Path | None = None
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Core Services Module
|
|
2
|
+
|
|
3
|
+
**IMMUTABLE SCAFFOLDING CODE** - These are the framework's core services that provide essential functionality.
|
|
4
|
+
|
|
5
|
+
## What's Here
|
|
6
|
+
|
|
7
|
+
This module contains the scaffolding's core services:
|
|
8
|
+
|
|
9
|
+
- **email.py** - Email sending via AWS SES
|
|
10
|
+
- **blob.py** - File storage and blob management
|
|
11
|
+
|
|
12
|
+
## Important Rules
|
|
13
|
+
|
|
14
|
+
⚠️ **DO NOT MODIFY** these core services directly.
|
|
15
|
+
|
|
16
|
+
**For changes to core services:**
|
|
17
|
+
|
|
18
|
+
- File an issue at `https://github.com/alltuner/scaffolding`
|
|
19
|
+
- Core changes benefit all projects using the scaffolding
|
|
20
|
+
|
|
21
|
+
**For your application services:**
|
|
22
|
+
|
|
23
|
+
- Create them in `src/app/services/` instead
|
|
24
|
+
- Import core services when needed: `from vibetuner.services.email import send_email`
|
|
25
|
+
|
|
26
|
+
## User Service Pattern (for reference)
|
|
27
|
+
|
|
28
|
+
Your application services in `src/app/services/` should follow this pattern:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from vibetuner.models import UserModel
|
|
32
|
+
|
|
33
|
+
class NotificationService:
|
|
34
|
+
async def send_notification(
|
|
35
|
+
self,
|
|
36
|
+
user: UserModel,
|
|
37
|
+
message: str,
|
|
38
|
+
priority: str = "normal"
|
|
39
|
+
) -> bool:
|
|
40
|
+
# Implementation
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
# Singleton
|
|
44
|
+
notification_service = NotificationService()
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Using Core Services
|
|
48
|
+
|
|
49
|
+
### Email Service
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from vibetuner.services.email import send_email
|
|
53
|
+
|
|
54
|
+
await send_email(
|
|
55
|
+
to_email="user@example.com",
|
|
56
|
+
subject="Welcome",
|
|
57
|
+
html_content="<h1>Welcome!</h1>",
|
|
58
|
+
text_content="Welcome!"
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Blob Service
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from vibetuner.services.blob import blob_service
|
|
66
|
+
|
|
67
|
+
# Upload file
|
|
68
|
+
blob = await blob_service.upload(file_data, "image.png")
|
|
69
|
+
|
|
70
|
+
# Get file URL
|
|
71
|
+
url = await blob_service.get_url(blob.id)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Creating Your Own Services
|
|
75
|
+
|
|
76
|
+
Place your application services in `src/app/services/`:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# src/app/services/external_api.py
|
|
80
|
+
import httpx
|
|
81
|
+
|
|
82
|
+
async def call_api(api_url: str, api_key: str, data: dict) -> dict:
|
|
83
|
+
async with httpx.AsyncClient() as client:
|
|
84
|
+
response = await client.post(
|
|
85
|
+
api_url,
|
|
86
|
+
json=data,
|
|
87
|
+
headers={"Authorization": f"Bearer {api_key}"}
|
|
88
|
+
)
|
|
89
|
+
response.raise_for_status()
|
|
90
|
+
return response.json()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Dependency Injection
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from fastapi import Depends
|
|
97
|
+
|
|
98
|
+
@router.post("/notify")
|
|
99
|
+
async def notify(
|
|
100
|
+
message: str,
|
|
101
|
+
service=Depends(lambda: notification_service)
|
|
102
|
+
):
|
|
103
|
+
await service.send_notification(user, message)
|
|
104
|
+
```
|