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,94 @@
|
|
|
1
|
+
from fastapi import APIRouter, Form, HTTPException, Request
|
|
2
|
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
3
|
+
from pydantic_extra_types.language_code import LanguageAlpha2
|
|
4
|
+
from starlette.authentication import requires
|
|
5
|
+
|
|
6
|
+
from vibetuner.context import ctx
|
|
7
|
+
from vibetuner.models import UserModel
|
|
8
|
+
|
|
9
|
+
from ..templates import render_template
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/user")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.get("/")
|
|
16
|
+
@requires("authenticated", redirect="auth_login")
|
|
17
|
+
async def user_profile(request: Request) -> HTMLResponse:
|
|
18
|
+
"""User profile endpoint."""
|
|
19
|
+
user = await UserModel.get(request.user.id)
|
|
20
|
+
if not user:
|
|
21
|
+
raise HTTPException(
|
|
22
|
+
status_code=404,
|
|
23
|
+
detail="User not found",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
await user.fetch_link("oauth_accounts")
|
|
27
|
+
return render_template(
|
|
28
|
+
"user/profile.html.jinja",
|
|
29
|
+
request,
|
|
30
|
+
{"user": user},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.get("/edit")
|
|
35
|
+
@requires("authenticated", redirect="auth_login")
|
|
36
|
+
async def user_edit_form(request: Request) -> HTMLResponse:
|
|
37
|
+
"""User profile edit form."""
|
|
38
|
+
user = await UserModel.get(request.user.id)
|
|
39
|
+
if not user:
|
|
40
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
41
|
+
|
|
42
|
+
# Get available languages for the dropdown
|
|
43
|
+
from babel import Locale
|
|
44
|
+
|
|
45
|
+
locale_names = dict(
|
|
46
|
+
sorted(
|
|
47
|
+
{
|
|
48
|
+
locale: (Locale.parse(locale).display_name or locale).capitalize()
|
|
49
|
+
for locale in ctx.supported_languages
|
|
50
|
+
}.items(),
|
|
51
|
+
key=lambda x: x[1],
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return render_template(
|
|
56
|
+
"user/edit.html.jinja",
|
|
57
|
+
request,
|
|
58
|
+
{
|
|
59
|
+
"user": user,
|
|
60
|
+
"locale_names": locale_names,
|
|
61
|
+
"current_language": user.user_settings.language,
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@router.post("/edit")
|
|
67
|
+
@requires("authenticated", redirect="auth_login")
|
|
68
|
+
async def user_edit_submit(
|
|
69
|
+
request: Request,
|
|
70
|
+
name: str = Form(...),
|
|
71
|
+
language: str = Form(None),
|
|
72
|
+
) -> RedirectResponse:
|
|
73
|
+
"""Handle user profile edit form submission."""
|
|
74
|
+
user = await UserModel.get(request.user.id)
|
|
75
|
+
if not user:
|
|
76
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
77
|
+
|
|
78
|
+
# Update user fields
|
|
79
|
+
user.name = name
|
|
80
|
+
|
|
81
|
+
# Update language preference if provided
|
|
82
|
+
if language and language in ctx.supported_languages:
|
|
83
|
+
try:
|
|
84
|
+
user.user_settings.language = LanguageAlpha2(language)
|
|
85
|
+
except ValueError:
|
|
86
|
+
pass # Invalid language code, skip update
|
|
87
|
+
|
|
88
|
+
# Save user
|
|
89
|
+
await user.save()
|
|
90
|
+
|
|
91
|
+
# Update session with new data to avoid DB query on next request
|
|
92
|
+
request.session["user"] = user.session_dict
|
|
93
|
+
|
|
94
|
+
return RedirectResponse(url="/user/", status_code=302)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from fastapi import Request
|
|
5
|
+
from fastapi.templating import Jinja2Templates
|
|
6
|
+
from starlette.responses import HTMLResponse
|
|
7
|
+
from starlette_babel import gettext_lazy as _, gettext_lazy as ngettext
|
|
8
|
+
from starlette_babel.contrib.jinja import configure_jinja_env
|
|
9
|
+
|
|
10
|
+
from vibetuner.context import Context
|
|
11
|
+
from vibetuner.paths import frontend_templates
|
|
12
|
+
from vibetuner.templates import render_static_template
|
|
13
|
+
from vibetuner.time import age_in_timedelta
|
|
14
|
+
|
|
15
|
+
from .hotreload import hotreload
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"render_static_template",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
data_ctx = Context()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def timeago(dt):
|
|
26
|
+
"""Converts a datetime object to a human-readable string representing the time elapsed since the given datetime.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
dt (datetime): The datetime object to convert.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
str: A human-readable string representing the time elapsed since the given datetime,
|
|
33
|
+
such as "X seconds ago", "X minutes ago", "X hours ago", "yesterday", "X days ago",
|
|
34
|
+
"X months ago", or "X years ago". If the datetime is more than 4 years old,
|
|
35
|
+
it returns the date in the format "MMM DD, YYYY".
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
diff = age_in_timedelta(dt)
|
|
40
|
+
|
|
41
|
+
if diff < timedelta(seconds=60):
|
|
42
|
+
seconds = diff.seconds
|
|
43
|
+
return ngettext(
|
|
44
|
+
"%(seconds)d second ago",
|
|
45
|
+
"%(seconds)d seconds ago",
|
|
46
|
+
seconds,
|
|
47
|
+
) % {"seconds": seconds}
|
|
48
|
+
if diff < timedelta(minutes=60):
|
|
49
|
+
minutes = diff.seconds // 60
|
|
50
|
+
return ngettext(
|
|
51
|
+
"%(minutes)d minute ago",
|
|
52
|
+
"%(minutes)d minutes ago",
|
|
53
|
+
minutes,
|
|
54
|
+
) % {"minutes": minutes}
|
|
55
|
+
if diff < timedelta(days=1):
|
|
56
|
+
hours = diff.seconds // 3600
|
|
57
|
+
return ngettext("%(hours)d hour ago", "%(hours)d hours ago", hours) % {
|
|
58
|
+
"hours": hours,
|
|
59
|
+
}
|
|
60
|
+
if diff < timedelta(days=2):
|
|
61
|
+
return _("yesterday")
|
|
62
|
+
if diff < timedelta(days=65):
|
|
63
|
+
days = diff.days
|
|
64
|
+
return ngettext("%(days)d day ago", "%(days)d days ago", days) % {
|
|
65
|
+
"days": days,
|
|
66
|
+
}
|
|
67
|
+
if diff < timedelta(days=365):
|
|
68
|
+
months = diff.days // 30
|
|
69
|
+
return ngettext("%(months)d month ago", "%(months)d months ago", months) % {
|
|
70
|
+
"months": months,
|
|
71
|
+
}
|
|
72
|
+
if diff < timedelta(days=365 * 4):
|
|
73
|
+
years = diff.days // 365
|
|
74
|
+
return ngettext("%(years)d year ago", "%(years)d years ago", years) % {
|
|
75
|
+
"years": years,
|
|
76
|
+
}
|
|
77
|
+
return dt.strftime("%b %d, %Y")
|
|
78
|
+
except Exception:
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_date(dt):
|
|
83
|
+
"""Formats a datetime object to display only the date.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
dt (datetime): The datetime object to format.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
str: A formatted date string in the format "Month DD, YYYY" (e.g., "January 15, 2024").
|
|
90
|
+
Returns empty string if dt is None.
|
|
91
|
+
"""
|
|
92
|
+
if dt is None:
|
|
93
|
+
return ""
|
|
94
|
+
try:
|
|
95
|
+
return dt.strftime("%B %d, %Y")
|
|
96
|
+
except Exception:
|
|
97
|
+
return ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def format_datetime(dt):
|
|
101
|
+
"""Formats a datetime object to display date and time without seconds.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
dt (datetime): The datetime object to format.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
str: A formatted datetime string in the format "Month DD, YYYY at HH:MM AM/PM"
|
|
108
|
+
(e.g., "January 15, 2024 at 3:45 PM"). Returns empty string if dt is None.
|
|
109
|
+
"""
|
|
110
|
+
if dt is None:
|
|
111
|
+
return ""
|
|
112
|
+
try:
|
|
113
|
+
return dt.strftime("%B %d, %Y at %I:%M %p")
|
|
114
|
+
except Exception:
|
|
115
|
+
return ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Add your functions here
|
|
119
|
+
def format_duration(seconds):
|
|
120
|
+
"""Formats duration in seconds to user-friendly format with rounding.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
seconds (float): Duration in seconds.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
str: For 0-45 seconds, shows "x sec" (e.g., "30 sec").
|
|
127
|
+
For 46 seconds to 1:45, shows "1 min".
|
|
128
|
+
For 1:46 to 2:45, shows "2 min", etc.
|
|
129
|
+
Returns empty string if seconds is None or invalid.
|
|
130
|
+
"""
|
|
131
|
+
if seconds is None:
|
|
132
|
+
return ""
|
|
133
|
+
try:
|
|
134
|
+
total_seconds = int(float(seconds))
|
|
135
|
+
|
|
136
|
+
if total_seconds <= 45:
|
|
137
|
+
return f"{total_seconds} sec"
|
|
138
|
+
else:
|
|
139
|
+
# Round to nearest minute for times > 45 seconds
|
|
140
|
+
# 46-105 seconds = 1 min, 106-165 seconds = 2 min, etc.
|
|
141
|
+
minutes = round(total_seconds / 60)
|
|
142
|
+
return f"{minutes} min"
|
|
143
|
+
except (ValueError, TypeError):
|
|
144
|
+
return ""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
templates: Jinja2Templates = Jinja2Templates(directory=frontend_templates)
|
|
148
|
+
jinja_env = templates.env
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def render_template(
|
|
152
|
+
template: str,
|
|
153
|
+
request: Request,
|
|
154
|
+
ctx: dict[str, Any] | None = None,
|
|
155
|
+
**kwargs: Any,
|
|
156
|
+
) -> HTMLResponse:
|
|
157
|
+
ctx = ctx or {}
|
|
158
|
+
merged_ctx = {**data_ctx.model_dump(), "request": request, **ctx}
|
|
159
|
+
|
|
160
|
+
return templates.TemplateResponse(template, merged_ctx, **kwargs)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# Global Vars
|
|
164
|
+
jinja_env.globals.update({"DEBUG": data_ctx.DEBUG})
|
|
165
|
+
jinja_env.globals.update({"hotreload": hotreload})
|
|
166
|
+
|
|
167
|
+
# Date Filters
|
|
168
|
+
jinja_env.filters["timeago"] = timeago
|
|
169
|
+
jinja_env.filters["format_date"] = format_date
|
|
170
|
+
jinja_env.filters["format_datetime"] = format_datetime
|
|
171
|
+
|
|
172
|
+
# Duration Filters
|
|
173
|
+
jinja_env.filters["format_duration"] = format_duration
|
|
174
|
+
jinja_env.filters["duration"] = format_duration
|
|
175
|
+
|
|
176
|
+
configure_jinja_env(jinja_env)
|
vibetuner/logging.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LogLevel(str, Enum):
|
|
10
|
+
DEBUG = "DEBUG"
|
|
11
|
+
INFO = "INFO"
|
|
12
|
+
WARNING = "WARNING"
|
|
13
|
+
ERROR = "ERROR"
|
|
14
|
+
CRITICAL = "CRITICAL"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InterceptHandler(logging.Handler):
|
|
18
|
+
def emit(self, record):
|
|
19
|
+
# Get corresponding Loguru level if it exists
|
|
20
|
+
try:
|
|
21
|
+
level = logger.level(record.levelname).name
|
|
22
|
+
except ValueError:
|
|
23
|
+
level = record.levelno
|
|
24
|
+
|
|
25
|
+
# Find caller from where originated the logged message
|
|
26
|
+
frame, depth = logging.currentframe(), 2
|
|
27
|
+
while frame is not None and frame.f_code.co_filename == logging.__file__:
|
|
28
|
+
frame = frame.f_back
|
|
29
|
+
depth += 1
|
|
30
|
+
|
|
31
|
+
# Format the message with module information inline
|
|
32
|
+
logger.opt(depth=depth, exception=record.exc_info).log(
|
|
33
|
+
level,
|
|
34
|
+
f"[{record.name}] {record.getMessage()}",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def setup_logging(
|
|
39
|
+
level: str | int | None = LogLevel.INFO,
|
|
40
|
+
log_file: str | Path | None = None,
|
|
41
|
+
rotation: str = "10 MB",
|
|
42
|
+
retention: str = "1 week",
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Configure logging for the application.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
level: Minimum logging level threshold
|
|
48
|
+
log_file: Optional path to log file. If None, logs only to stderr
|
|
49
|
+
rotation: When to rotate the log file
|
|
50
|
+
retention: How long to keep log files
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
# Remove default handler
|
|
54
|
+
logger.remove()
|
|
55
|
+
|
|
56
|
+
if level is None:
|
|
57
|
+
level = LogLevel.INFO
|
|
58
|
+
|
|
59
|
+
# Define format for the logs
|
|
60
|
+
format_string = (
|
|
61
|
+
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
|
62
|
+
"<level>{level: <8}</level> | "
|
|
63
|
+
"<cyan>{name}</cyan>:<cyan>{line}</cyan> - "
|
|
64
|
+
"<level>{message}</level>"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Add stderr handler with custom format
|
|
68
|
+
logger.add(sys.stderr, level=level, format=format_string, colorize=True)
|
|
69
|
+
|
|
70
|
+
# Add file handler if log_file is specified
|
|
71
|
+
if log_file:
|
|
72
|
+
logger.add(
|
|
73
|
+
str(log_file),
|
|
74
|
+
level=level,
|
|
75
|
+
format=format_string,
|
|
76
|
+
rotation=rotation,
|
|
77
|
+
retention=retention,
|
|
78
|
+
compression="zip",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Intercept standard logging
|
|
82
|
+
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
|
|
83
|
+
|
|
84
|
+
# Optional: Capture warnings from warnings module
|
|
85
|
+
logging.captureWarnings(True)
|
|
86
|
+
|
|
87
|
+
logger.info("Logging configured successfully")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from beanie import Document, View
|
|
2
|
+
|
|
3
|
+
from .blob import BlobModel
|
|
4
|
+
from .email_verification import EmailVerificationTokenModel
|
|
5
|
+
from .oauth import OAuthAccountModel
|
|
6
|
+
from .user import UserModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__: list[type[Document] | type[View]] = [
|
|
10
|
+
BlobModel,
|
|
11
|
+
EmailVerificationTokenModel,
|
|
12
|
+
OAuthAccountModel,
|
|
13
|
+
UserModel,
|
|
14
|
+
]
|
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
|