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
|
@@ -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.models import UserModel
|
|
7
|
+
|
|
8
|
+
from ..context import ctx
|
|
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,165 @@
|
|
|
1
|
+
# Core Models Module
|
|
2
|
+
|
|
3
|
+
**IMMUTABLE SCAFFOLDING CODE** - These are the framework's core models that provide essential functionality.
|
|
4
|
+
|
|
5
|
+
## What's Here
|
|
6
|
+
|
|
7
|
+
This module contains the scaffolding's core models:
|
|
8
|
+
|
|
9
|
+
- **UserModel** - Base user model with authentication support
|
|
10
|
+
- **OAuthAccountModel** - OAuth provider account linking
|
|
11
|
+
- **EmailVerificationTokenModel** - Magic link authentication tokens
|
|
12
|
+
- **BlobModel** - File storage and blob management
|
|
13
|
+
- **Mixins** - Reusable model behaviors (TimeStampMixin, etc.)
|
|
14
|
+
- **Types** - Common field types and validators
|
|
15
|
+
|
|
16
|
+
## Important Rules
|
|
17
|
+
|
|
18
|
+
⚠️ **DO NOT MODIFY** these core models directly.
|
|
19
|
+
|
|
20
|
+
**For changes to core models:**
|
|
21
|
+
|
|
22
|
+
- File an issue at `https://github.com/alltuner/scaffolding`
|
|
23
|
+
- Core changes benefit all projects using the scaffolding
|
|
24
|
+
|
|
25
|
+
**For your application models:**
|
|
26
|
+
|
|
27
|
+
- Create them in `src/app/models/` instead
|
|
28
|
+
- Import core models when needed: `from vibetuner.models import UserModel`
|
|
29
|
+
- Use mixins from here: `from vibetuner.models.mixins import TimeStampMixin`
|
|
30
|
+
|
|
31
|
+
## User Model Pattern (for reference)
|
|
32
|
+
|
|
33
|
+
Your application models in `src/app/models/` should follow this pattern:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from beanie import Document
|
|
37
|
+
from pydantic import Field
|
|
38
|
+
from vibetuner.models.mixins import TimeStampMixin
|
|
39
|
+
|
|
40
|
+
class Product(Document, TimeStampMixin):
|
|
41
|
+
name: str
|
|
42
|
+
price: float = Field(gt=0)
|
|
43
|
+
stock: int = Field(ge=0)
|
|
44
|
+
|
|
45
|
+
class Settings:
|
|
46
|
+
name = "products"
|
|
47
|
+
indexes = ["name"]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Available Mixins
|
|
51
|
+
|
|
52
|
+
### TimeStampMixin
|
|
53
|
+
|
|
54
|
+
Automatic timestamps for all models:
|
|
55
|
+
|
|
56
|
+
- `db_insert_dt` - Created at (UTC)
|
|
57
|
+
- `db_update_dt` - Updated at (UTC)
|
|
58
|
+
- Methods: `age()`, `age_in()`, `is_older_than()`
|
|
59
|
+
|
|
60
|
+
Import in your app models:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from vibetuner.models.mixins import TimeStampMixin
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Queries
|
|
67
|
+
|
|
68
|
+
### Finding Documents
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from beanie.operators import Eq, In, Gt, Lt
|
|
72
|
+
|
|
73
|
+
# By ID (preferred method)
|
|
74
|
+
product = await Product.get(product_id)
|
|
75
|
+
|
|
76
|
+
# By field (use Beanie operators)
|
|
77
|
+
product = await Product.find_one(Eq(Product.name, "Widget"))
|
|
78
|
+
products = await Product.find(Lt(Product.price, 100)).to_list()
|
|
79
|
+
|
|
80
|
+
# Multiple conditions
|
|
81
|
+
results = await Product.find(
|
|
82
|
+
Eq(Product.category, "electronics"),
|
|
83
|
+
Gt(Product.price, 50)
|
|
84
|
+
).to_list()
|
|
85
|
+
|
|
86
|
+
# With In operator
|
|
87
|
+
products = await Product.find(
|
|
88
|
+
In(Product.category, ["electronics", "gadgets"])
|
|
89
|
+
).to_list()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Save/Delete
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# Create
|
|
96
|
+
product = Product(name="Widget", price=9.99, stock=100)
|
|
97
|
+
await product.insert()
|
|
98
|
+
|
|
99
|
+
# Update
|
|
100
|
+
product.price = 19.99
|
|
101
|
+
await product.save()
|
|
102
|
+
|
|
103
|
+
# Delete
|
|
104
|
+
await product.delete()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Aggregation
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
results = await Product.aggregate([
|
|
111
|
+
{"$match": {"price": {"$gt": 50}}},
|
|
112
|
+
{"$group": {"_id": "$category", "total": {"$sum": 1}}}
|
|
113
|
+
]).to_list()
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Indexes
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from pymongo import IndexModel, TEXT
|
|
120
|
+
|
|
121
|
+
class Settings:
|
|
122
|
+
indexes = [
|
|
123
|
+
"field_name", # Simple index
|
|
124
|
+
[("field1", 1), ("field2", -1)], # Compound index
|
|
125
|
+
IndexModel([("text_field", TEXT)]) # Text search
|
|
126
|
+
]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Relationships
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from beanie import Link
|
|
133
|
+
|
|
134
|
+
class Order(Document):
|
|
135
|
+
user: Link[User]
|
|
136
|
+
products: list[Link[Product]]
|
|
137
|
+
|
|
138
|
+
# Fetch with relations
|
|
139
|
+
order = await Order.get(order_id, fetch_links=True)
|
|
140
|
+
print(order.user.email) # Automatically loaded
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Extending Core Models
|
|
144
|
+
|
|
145
|
+
If you need to add fields to User or other core models:
|
|
146
|
+
|
|
147
|
+
1. **Option A**: File an issue at `https://github.com/alltuner/scaffolding` for widely useful fields
|
|
148
|
+
2. **Option B**: Create a related model in `src/app/models/` that links to the core model:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from beanie import Document, Link
|
|
152
|
+
from vibetuner.models import UserModel
|
|
153
|
+
|
|
154
|
+
class UserProfile(Document):
|
|
155
|
+
user: Link[UserModel]
|
|
156
|
+
bio: str
|
|
157
|
+
avatar_url: str
|
|
158
|
+
|
|
159
|
+
class Settings:
|
|
160
|
+
name = "user_profiles"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## MongoDB MCP
|
|
164
|
+
|
|
165
|
+
Claude Code has MongoDB MCP access for database operations, queries, and debugging.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Core Models Module
|
|
2
|
+
|
|
3
|
+
**IMMUTABLE SCAFFOLDING CODE** - These are the framework's core models that provide essential functionality.
|
|
4
|
+
|
|
5
|
+
## What's Here
|
|
6
|
+
|
|
7
|
+
This module contains the scaffolding's core models:
|
|
8
|
+
|
|
9
|
+
- **UserModel** - Base user model with authentication support
|
|
10
|
+
- **OAuthAccountModel** - OAuth provider account linking
|
|
11
|
+
- **EmailVerificationTokenModel** - Magic link authentication tokens
|
|
12
|
+
- **BlobModel** - File storage and blob management
|
|
13
|
+
- **Mixins** - Reusable model behaviors (TimeStampMixin, etc.)
|
|
14
|
+
- **Types** - Common field types and validators
|
|
15
|
+
|
|
16
|
+
## Important Rules
|
|
17
|
+
|
|
18
|
+
⚠️ **DO NOT MODIFY** these core models directly.
|
|
19
|
+
|
|
20
|
+
**For changes to core models:**
|
|
21
|
+
|
|
22
|
+
- File an issue at `https://github.com/alltuner/scaffolding`
|
|
23
|
+
- Core changes benefit all projects using the scaffolding
|
|
24
|
+
|
|
25
|
+
**For your application models:**
|
|
26
|
+
|
|
27
|
+
- Create them in `src/app/models/` instead
|
|
28
|
+
- Import core models when needed: `from vibetuner.models import UserModel`
|
|
29
|
+
- Use mixins from here: `from vibetuner.models.mixins import TimeStampMixin`
|
|
30
|
+
|
|
31
|
+
## User Model Pattern (for reference)
|
|
32
|
+
|
|
33
|
+
Your application models in `src/app/models/` should follow this pattern:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from beanie import Document
|
|
37
|
+
from pydantic import Field
|
|
38
|
+
from vibetuner.models.mixins import TimeStampMixin
|
|
39
|
+
|
|
40
|
+
class Product(Document, TimeStampMixin):
|
|
41
|
+
name: str
|
|
42
|
+
price: float = Field(gt=0)
|
|
43
|
+
stock: int = Field(ge=0)
|
|
44
|
+
|
|
45
|
+
class Settings:
|
|
46
|
+
name = "products"
|
|
47
|
+
indexes = ["name"]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Available Mixins
|
|
51
|
+
|
|
52
|
+
### TimeStampMixin
|
|
53
|
+
|
|
54
|
+
Automatic timestamps for all models:
|
|
55
|
+
|
|
56
|
+
- `db_insert_dt` - Created at (UTC)
|
|
57
|
+
- `db_update_dt` - Updated at (UTC)
|
|
58
|
+
- Methods: `age()`, `age_in()`, `is_older_than()`
|
|
59
|
+
|
|
60
|
+
Import in your app models:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from vibetuner.models.mixins import TimeStampMixin
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Queries
|
|
67
|
+
|
|
68
|
+
### Finding Documents
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from beanie.operators import Eq, In, Gt, Lt
|
|
72
|
+
|
|
73
|
+
# By ID (preferred method)
|
|
74
|
+
product = await Product.get(product_id)
|
|
75
|
+
|
|
76
|
+
# By field (use Beanie operators)
|
|
77
|
+
product = await Product.find_one(Eq(Product.name, "Widget"))
|
|
78
|
+
products = await Product.find(Lt(Product.price, 100)).to_list()
|
|
79
|
+
|
|
80
|
+
# Multiple conditions
|
|
81
|
+
results = await Product.find(
|
|
82
|
+
Eq(Product.category, "electronics"),
|
|
83
|
+
Gt(Product.price, 50)
|
|
84
|
+
).to_list()
|
|
85
|
+
|
|
86
|
+
# With In operator
|
|
87
|
+
products = await Product.find(
|
|
88
|
+
In(Product.category, ["electronics", "gadgets"])
|
|
89
|
+
).to_list()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Save/Delete
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# Create
|
|
96
|
+
product = Product(name="Widget", price=9.99, stock=100)
|
|
97
|
+
await product.insert()
|
|
98
|
+
|
|
99
|
+
# Update
|
|
100
|
+
product.price = 19.99
|
|
101
|
+
await product.save()
|
|
102
|
+
|
|
103
|
+
# Delete
|
|
104
|
+
await product.delete()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Aggregation
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
results = await Product.aggregate([
|
|
111
|
+
{"$match": {"price": {"$gt": 50}}},
|
|
112
|
+
{"$group": {"_id": "$category", "total": {"$sum": 1}}}
|
|
113
|
+
]).to_list()
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Indexes
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from pymongo import IndexModel, TEXT
|
|
120
|
+
|
|
121
|
+
class Settings:
|
|
122
|
+
indexes = [
|
|
123
|
+
"field_name", # Simple index
|
|
124
|
+
[("field1", 1), ("field2", -1)], # Compound index
|
|
125
|
+
IndexModel([("text_field", TEXT)]) # Text search
|
|
126
|
+
]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Relationships
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from beanie import Link
|
|
133
|
+
|
|
134
|
+
class Order(Document):
|
|
135
|
+
user: Link[User]
|
|
136
|
+
products: list[Link[Product]]
|
|
137
|
+
|
|
138
|
+
# Fetch with relations
|
|
139
|
+
order = await Order.get(order_id, fetch_links=True)
|
|
140
|
+
print(order.user.email) # Automatically loaded
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Extending Core Models
|
|
144
|
+
|
|
145
|
+
If you need to add fields to User or other core models:
|
|
146
|
+
|
|
147
|
+
1. **Option A**: File an issue at `https://github.com/alltuner/scaffolding` for widely useful fields
|
|
148
|
+
2. **Option B**: Create a related model in `src/app/models/` that links to the core model:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from beanie import Document, Link
|
|
152
|
+
from vibetuner.models import UserModel
|
|
153
|
+
|
|
154
|
+
class UserProfile(Document):
|
|
155
|
+
user: Link[UserModel]
|
|
156
|
+
bio: str
|
|
157
|
+
avatar_url: str
|
|
158
|
+
|
|
159
|
+
class Settings:
|
|
160
|
+
name = "user_profiles"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## MongoDB MCP
|
|
164
|
+
|
|
165
|
+
Claude Code has MongoDB MCP access for database operations, queries, and debugging.
|
|
@@ -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
|
+
]
|