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,156 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fastapi import (
|
|
4
|
+
APIRouter,
|
|
5
|
+
BackgroundTasks,
|
|
6
|
+
Depends,
|
|
7
|
+
Form,
|
|
8
|
+
HTTPException,
|
|
9
|
+
Request,
|
|
10
|
+
)
|
|
11
|
+
from fastapi.responses import RedirectResponse
|
|
12
|
+
from pydantic import EmailStr
|
|
13
|
+
from starlette.responses import HTMLResponse
|
|
14
|
+
|
|
15
|
+
from vibetuner.models import EmailVerificationTokenModel, UserModel
|
|
16
|
+
from vibetuner.services.email import SESEmailService
|
|
17
|
+
|
|
18
|
+
from ..email import send_magic_link_email
|
|
19
|
+
from ..oauth import (
|
|
20
|
+
_create_auth_handler,
|
|
21
|
+
_create_auth_login_handler,
|
|
22
|
+
get_oauth_providers,
|
|
23
|
+
)
|
|
24
|
+
from ..templates import render_template
|
|
25
|
+
from . import get_homepage_url
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_ses_service() -> SESEmailService:
|
|
29
|
+
return SESEmailService()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def logout_user(request: Request):
|
|
33
|
+
request.session.pop("user", None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
router = APIRouter(prefix="/auth")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get(
|
|
40
|
+
"/logout",
|
|
41
|
+
dependencies=[Depends(logout_user)],
|
|
42
|
+
response_class=RedirectResponse,
|
|
43
|
+
status_code=307,
|
|
44
|
+
)
|
|
45
|
+
async def auth_logout(request: Request):
|
|
46
|
+
return get_homepage_url(request)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.get("/login", response_model=None)
|
|
50
|
+
async def auth_login(
|
|
51
|
+
request: Request,
|
|
52
|
+
next: str | None = None,
|
|
53
|
+
) -> RedirectResponse | HTMLResponse:
|
|
54
|
+
"""Display unified login page with all available options"""
|
|
55
|
+
if request.user.is_authenticated:
|
|
56
|
+
# If user is already authenticated, redirect to homepage
|
|
57
|
+
return RedirectResponse(url=get_homepage_url(request), status_code=302)
|
|
58
|
+
|
|
59
|
+
oauth_providers = get_oauth_providers()
|
|
60
|
+
return render_template(
|
|
61
|
+
"login.html.jinja",
|
|
62
|
+
request=request,
|
|
63
|
+
ctx={
|
|
64
|
+
"providers": oauth_providers,
|
|
65
|
+
"next": next,
|
|
66
|
+
"has_oauth": bool(oauth_providers),
|
|
67
|
+
"has_email": True,
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post("/magic-link-login", response_model=None)
|
|
73
|
+
async def send_magic_link(
|
|
74
|
+
request: Request,
|
|
75
|
+
ses_service: Annotated[SESEmailService, Depends(get_ses_service)],
|
|
76
|
+
background_tasks: BackgroundTasks,
|
|
77
|
+
email: Annotated[EmailStr, Form()],
|
|
78
|
+
next: Annotated[str | None, Form()] = None,
|
|
79
|
+
) -> HTMLResponse:
|
|
80
|
+
"""Handle email magic link login form submission"""
|
|
81
|
+
|
|
82
|
+
# Create verification token
|
|
83
|
+
verification_token = await EmailVerificationTokenModel.create_token(email)
|
|
84
|
+
|
|
85
|
+
# Build login URL
|
|
86
|
+
login_url = request.url_for("email_verify", token=verification_token.token)
|
|
87
|
+
if next:
|
|
88
|
+
login_url = login_url.include_query_params(next=next)
|
|
89
|
+
|
|
90
|
+
background_tasks.add_task(
|
|
91
|
+
send_magic_link_email,
|
|
92
|
+
ses_service=ses_service,
|
|
93
|
+
lang=request.state.language,
|
|
94
|
+
to_address=email,
|
|
95
|
+
login_url=str(login_url),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return render_template(
|
|
99
|
+
"email_sent.html.jinja", request=request, ctx={"email": email, "next": next}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@router.get(
|
|
104
|
+
"/email-verify/{token}",
|
|
105
|
+
response_class=RedirectResponse,
|
|
106
|
+
status_code=302,
|
|
107
|
+
response_model=None,
|
|
108
|
+
)
|
|
109
|
+
async def email_verify(
|
|
110
|
+
request: Request,
|
|
111
|
+
token: str,
|
|
112
|
+
next: str | None = None,
|
|
113
|
+
) -> str:
|
|
114
|
+
"""Verify email token and log in user"""
|
|
115
|
+
# Verify token
|
|
116
|
+
verification_token = await EmailVerificationTokenModel.verify_token(token)
|
|
117
|
+
if not verification_token:
|
|
118
|
+
raise HTTPException(status_code=400, detail="Invalid or expired token")
|
|
119
|
+
|
|
120
|
+
# Get or create user
|
|
121
|
+
user = await UserModel.get_by_email(verification_token.email)
|
|
122
|
+
if not user:
|
|
123
|
+
# Create new user
|
|
124
|
+
user = UserModel(
|
|
125
|
+
email=verification_token.email,
|
|
126
|
+
# Use email prefix as default name
|
|
127
|
+
name=verification_token.email.split("@")[0],
|
|
128
|
+
)
|
|
129
|
+
await user.insert()
|
|
130
|
+
|
|
131
|
+
# Set session
|
|
132
|
+
request.session["user"] = user.session_dict
|
|
133
|
+
|
|
134
|
+
# Redirect
|
|
135
|
+
return next or get_homepage_url(request)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def register_oauth_routes() -> None:
|
|
139
|
+
"""Register OAuth provider routes dynamically.
|
|
140
|
+
|
|
141
|
+
This must be called after OAuth providers are registered to ensure
|
|
142
|
+
routes are created for all configured providers.
|
|
143
|
+
"""
|
|
144
|
+
for provider in get_oauth_providers():
|
|
145
|
+
router.get(
|
|
146
|
+
f"/provider/{provider}",
|
|
147
|
+
response_class=RedirectResponse,
|
|
148
|
+
name=f"auth_with_{provider}",
|
|
149
|
+
response_model=None,
|
|
150
|
+
)(_create_auth_handler(provider))
|
|
151
|
+
|
|
152
|
+
router.get(
|
|
153
|
+
f"/login/provider/{provider}",
|
|
154
|
+
name=f"login_with_{provider}",
|
|
155
|
+
response_model=None,
|
|
156
|
+
)(_create_auth_login_handler(provider))
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
from fastapi import (
|
|
2
|
+
APIRouter,
|
|
3
|
+
Depends,
|
|
4
|
+
HTTPException,
|
|
5
|
+
Request,
|
|
6
|
+
Response,
|
|
7
|
+
)
|
|
8
|
+
from fastapi.responses import (
|
|
9
|
+
HTMLResponse,
|
|
10
|
+
RedirectResponse,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from vibetuner.context import ctx
|
|
14
|
+
from vibetuner.models import UserModel
|
|
15
|
+
from vibetuner.models.registry import get_all_models
|
|
16
|
+
|
|
17
|
+
from ..deps import MAGIC_COOKIE_NAME
|
|
18
|
+
from ..templates import render_template
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def check_debug_access(request: Request, prod: str | None = None):
|
|
22
|
+
"""Check if debug routes should be accessible."""
|
|
23
|
+
# Always allow in development mode
|
|
24
|
+
if ctx.DEBUG:
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
# In production, require prod=1 parameter
|
|
28
|
+
if prod == "1":
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
# Deny access
|
|
32
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
router = APIRouter(prefix="/debug", dependencies=[Depends(check_debug_access)])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/", response_class=HTMLResponse)
|
|
39
|
+
def debug_index(request: Request):
|
|
40
|
+
return render_template("debug/index.html.jinja", request)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get("/magic")
|
|
44
|
+
def set_magic_cookie(response: Response):
|
|
45
|
+
"""Set the magic access cookie."""
|
|
46
|
+
response = RedirectResponse(url="/", status_code=302)
|
|
47
|
+
response.set_cookie(
|
|
48
|
+
key=MAGIC_COOKIE_NAME,
|
|
49
|
+
value="granted",
|
|
50
|
+
httponly=True,
|
|
51
|
+
secure=not ctx.DEBUG, # Only secure in production
|
|
52
|
+
samesite="lax",
|
|
53
|
+
max_age=86400 * 30, # 30 days
|
|
54
|
+
)
|
|
55
|
+
return response
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.get("/no-magic")
|
|
59
|
+
def remove_magic_cookie(response: Response):
|
|
60
|
+
"""Remove the magic access cookie."""
|
|
61
|
+
response = RedirectResponse(url="/", status_code=302)
|
|
62
|
+
response.delete_cookie(key=MAGIC_COOKIE_NAME)
|
|
63
|
+
return response
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@router.get("/version", response_class=HTMLResponse)
|
|
67
|
+
def debug_version(request: Request):
|
|
68
|
+
return render_template("debug/version.html.jinja", request)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.get("/info", response_class=HTMLResponse)
|
|
72
|
+
def debug_info(request: Request):
|
|
73
|
+
# Get cookies from request
|
|
74
|
+
cookies = dict(request.cookies)
|
|
75
|
+
|
|
76
|
+
# Get language from request state
|
|
77
|
+
language = getattr(request.state, "language", "Not set")
|
|
78
|
+
|
|
79
|
+
return render_template(
|
|
80
|
+
"debug/info.html.jinja", request, {"cookies": cookies, "language": language}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _extract_ref_name(ref: str) -> str:
|
|
85
|
+
"""Extract type name from JSON schema $ref."""
|
|
86
|
+
return ref.split("/")[-1]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_array_type(field_info: dict, field_name: str = "") -> str:
|
|
90
|
+
"""Parse array field type from JSON schema."""
|
|
91
|
+
if "items" not in field_info:
|
|
92
|
+
return "array[object]"
|
|
93
|
+
|
|
94
|
+
items = field_info["items"]
|
|
95
|
+
items_type = items.get("type", "")
|
|
96
|
+
|
|
97
|
+
# Handle union types in arrays (anyOf, oneOf)
|
|
98
|
+
if "anyOf" in items:
|
|
99
|
+
union_types = _parse_union_types(items, "anyOf", field_name)
|
|
100
|
+
return f"array[{union_types}]"
|
|
101
|
+
elif "oneOf" in items:
|
|
102
|
+
union_types = _parse_union_types(items, "oneOf", field_name)
|
|
103
|
+
return f"array[{union_types}]"
|
|
104
|
+
# Handle object references
|
|
105
|
+
elif items_type == "object" and "$ref" in items:
|
|
106
|
+
ref_name = _extract_ref_name(items["$ref"])
|
|
107
|
+
return f"array[{ref_name}]"
|
|
108
|
+
elif "$ref" in items:
|
|
109
|
+
ref_name = _extract_ref_name(items["$ref"])
|
|
110
|
+
return f"array[{ref_name}]"
|
|
111
|
+
# Handle nested arrays
|
|
112
|
+
elif items_type == "array":
|
|
113
|
+
nested_array_type = _parse_array_type(items, field_name)
|
|
114
|
+
return f"array[{nested_array_type}]"
|
|
115
|
+
else:
|
|
116
|
+
return f"array[{items_type or 'object'}]"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _is_beanie_link_schema(option: dict) -> bool:
|
|
120
|
+
"""Check if this schema represents a Beanie Link."""
|
|
121
|
+
if option.get("type") != "object":
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
properties = option.get("properties", {})
|
|
125
|
+
required = option.get("required", [])
|
|
126
|
+
|
|
127
|
+
# Beanie Link has id and collection properties
|
|
128
|
+
return (
|
|
129
|
+
"id" in properties
|
|
130
|
+
and "collection" in properties
|
|
131
|
+
and "id" in required
|
|
132
|
+
and "collection" in required
|
|
133
|
+
and len(properties) == 2
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _infer_link_target_from_field_name(field_name: str) -> str:
|
|
138
|
+
"""Infer the target model type from field name patterns."""
|
|
139
|
+
# Common patterns for field names that reference other models
|
|
140
|
+
patterns = {
|
|
141
|
+
"oauth_accounts": "OAuthAccountModel",
|
|
142
|
+
"accounts": "AccountModel",
|
|
143
|
+
"users": "UserModel",
|
|
144
|
+
"user": "UserModel",
|
|
145
|
+
"stations": "StationModel",
|
|
146
|
+
"station": "StationModel",
|
|
147
|
+
"rundowns": "RundownModel",
|
|
148
|
+
"rundown": "RundownModel",
|
|
149
|
+
"fillers": "FillerModel",
|
|
150
|
+
"filler": "FillerModel",
|
|
151
|
+
"voices": "VoiceModel",
|
|
152
|
+
"voice": "VoiceModel",
|
|
153
|
+
"blobs": "BlobModel",
|
|
154
|
+
"blob": "BlobModel",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# Direct lookup
|
|
158
|
+
if field_name in patterns:
|
|
159
|
+
return patterns[field_name]
|
|
160
|
+
|
|
161
|
+
# Try singular/plural conversions
|
|
162
|
+
if field_name.endswith("s"):
|
|
163
|
+
singular = field_name[:-1]
|
|
164
|
+
if singular in patterns:
|
|
165
|
+
return patterns[singular]
|
|
166
|
+
|
|
167
|
+
# Pattern-based inference (field_name -> FieldNameModel)
|
|
168
|
+
if "_" in field_name:
|
|
169
|
+
# Convert snake_case to PascalCase
|
|
170
|
+
parts = field_name.split("_")
|
|
171
|
+
model_name = "".join(word.capitalize() for word in parts) + "Model"
|
|
172
|
+
return model_name
|
|
173
|
+
else:
|
|
174
|
+
# Simple case: field_name -> FieldNameModel
|
|
175
|
+
return field_name.capitalize() + "Model"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _process_union_option(option: dict) -> tuple[str | None, bool]:
|
|
179
|
+
"""Process a single union option, return (type_name, is_link)."""
|
|
180
|
+
if "type" in option:
|
|
181
|
+
if _is_beanie_link_schema(option):
|
|
182
|
+
return None, True
|
|
183
|
+
else:
|
|
184
|
+
return option["type"], False
|
|
185
|
+
elif "$ref" in option:
|
|
186
|
+
ref_name = _extract_ref_name(option["$ref"])
|
|
187
|
+
return ref_name, False
|
|
188
|
+
elif "const" in option:
|
|
189
|
+
return f"'{option['const']}'", False
|
|
190
|
+
else:
|
|
191
|
+
if option.get("type") == "object" and option.get("additionalProperties"):
|
|
192
|
+
return None, False # Skip generic objects from Links
|
|
193
|
+
return "object", False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_union_types(field_info: dict, union_key: str, field_name: str = "") -> str:
|
|
197
|
+
"""Parse union types (anyOf, oneOf) from JSON schema."""
|
|
198
|
+
types = []
|
|
199
|
+
has_link = False
|
|
200
|
+
|
|
201
|
+
for option in field_info[union_key]:
|
|
202
|
+
type_name, is_link = _process_union_option(option)
|
|
203
|
+
if is_link:
|
|
204
|
+
has_link = True
|
|
205
|
+
elif type_name:
|
|
206
|
+
types.append(type_name)
|
|
207
|
+
|
|
208
|
+
if not types:
|
|
209
|
+
return union_key
|
|
210
|
+
|
|
211
|
+
# Add Link indicator with inferred target type
|
|
212
|
+
if has_link:
|
|
213
|
+
if field_name:
|
|
214
|
+
target_type = _infer_link_target_from_field_name(field_name)
|
|
215
|
+
return f"Link[{target_type}]"
|
|
216
|
+
else:
|
|
217
|
+
return f"Link[{types[0] if types else 'object'}]"
|
|
218
|
+
|
|
219
|
+
# If we have many types, show count to keep display clean
|
|
220
|
+
if len(types) > 4:
|
|
221
|
+
return f"{len(types)} types"
|
|
222
|
+
|
|
223
|
+
return " | ".join(types)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _handle_fallback_type(field_info: dict, field_name: str) -> str:
|
|
227
|
+
"""Handle fallback type inference when no explicit type is provided."""
|
|
228
|
+
if "properties" in field_info:
|
|
229
|
+
return "object"
|
|
230
|
+
elif "items" in field_info:
|
|
231
|
+
return "array"
|
|
232
|
+
elif "format" in field_info:
|
|
233
|
+
return field_info["format"]
|
|
234
|
+
else:
|
|
235
|
+
return field_name.split("_")[-1] if "_" in field_name else "any"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _parse_field_type(field_info: dict, field_name: str) -> str:
|
|
239
|
+
"""Parse field type from JSON schema field info."""
|
|
240
|
+
field_type = field_info.get("type", "")
|
|
241
|
+
|
|
242
|
+
# Handle array types
|
|
243
|
+
if field_type == "array":
|
|
244
|
+
return _parse_array_type(field_info, field_name)
|
|
245
|
+
|
|
246
|
+
# Handle object references
|
|
247
|
+
if "$ref" in field_info:
|
|
248
|
+
return _extract_ref_name(field_info["$ref"])
|
|
249
|
+
|
|
250
|
+
# Handle union types
|
|
251
|
+
if "anyOf" in field_info:
|
|
252
|
+
return _parse_union_types(field_info, "anyOf", field_name)
|
|
253
|
+
|
|
254
|
+
if "oneOf" in field_info:
|
|
255
|
+
return _parse_union_types(field_info, "oneOf", field_name)
|
|
256
|
+
|
|
257
|
+
# Handle inheritance
|
|
258
|
+
if "allOf" in field_info:
|
|
259
|
+
return "object"
|
|
260
|
+
|
|
261
|
+
# Handle const values
|
|
262
|
+
if "const" in field_info:
|
|
263
|
+
return f"const({field_info['const']})"
|
|
264
|
+
|
|
265
|
+
# Handle enum values
|
|
266
|
+
if "enum" in field_info:
|
|
267
|
+
return f"enum({len(field_info['enum'])} values)"
|
|
268
|
+
|
|
269
|
+
# Fallback type inference
|
|
270
|
+
if not field_type:
|
|
271
|
+
return _handle_fallback_type(field_info, field_name)
|
|
272
|
+
|
|
273
|
+
return field_type
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _get_extra_field_info(field_info: dict) -> dict:
|
|
277
|
+
"""Extract additional field metadata."""
|
|
278
|
+
extra = {}
|
|
279
|
+
|
|
280
|
+
# Add constraints if present
|
|
281
|
+
if "minimum" in field_info:
|
|
282
|
+
extra["min"] = field_info["minimum"]
|
|
283
|
+
if "maximum" in field_info:
|
|
284
|
+
extra["max"] = field_info["maximum"]
|
|
285
|
+
if "minLength" in field_info:
|
|
286
|
+
extra["min_length"] = field_info["minLength"]
|
|
287
|
+
if "maxLength" in field_info:
|
|
288
|
+
extra["max_length"] = field_info["maxLength"]
|
|
289
|
+
if "pattern" in field_info:
|
|
290
|
+
extra["pattern"] = field_info["pattern"]
|
|
291
|
+
if "format" in field_info:
|
|
292
|
+
extra["format"] = field_info["format"]
|
|
293
|
+
if "default" in field_info:
|
|
294
|
+
extra["default"] = field_info["default"]
|
|
295
|
+
if "enum" in field_info:
|
|
296
|
+
enum_values = field_info["enum"]
|
|
297
|
+
if len(enum_values) <= 5:
|
|
298
|
+
extra["enum"] = enum_values
|
|
299
|
+
else:
|
|
300
|
+
extra["enum_count"] = len(enum_values)
|
|
301
|
+
|
|
302
|
+
return extra
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _extract_fields_from_schema(schema: dict) -> list[dict]:
|
|
306
|
+
"""Extract field information from JSON schema."""
|
|
307
|
+
fields: list[dict] = []
|
|
308
|
+
|
|
309
|
+
if "properties" not in schema:
|
|
310
|
+
return fields
|
|
311
|
+
|
|
312
|
+
for field_name, field_info in schema["properties"].items():
|
|
313
|
+
field_type = _parse_field_type(field_info, field_name)
|
|
314
|
+
field_description = field_info.get("description", "")
|
|
315
|
+
required = field_name in schema.get("required", [])
|
|
316
|
+
extra_info = _get_extra_field_info(field_info)
|
|
317
|
+
|
|
318
|
+
fields.append(
|
|
319
|
+
{
|
|
320
|
+
"name": field_name,
|
|
321
|
+
"type": field_type,
|
|
322
|
+
"required": required,
|
|
323
|
+
"description": field_description,
|
|
324
|
+
"extra": extra_info,
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
return fields
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _get_collection_info(model) -> dict:
|
|
332
|
+
"""Extract collection information from a Beanie model."""
|
|
333
|
+
if hasattr(model, "Settings") and hasattr(model.Settings, "name"):
|
|
334
|
+
collection_name = model.Settings.name
|
|
335
|
+
else:
|
|
336
|
+
collection_name = model.__name__.lower()
|
|
337
|
+
|
|
338
|
+
schema = model.model_json_schema()
|
|
339
|
+
fields = _extract_fields_from_schema(schema)
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
"name": collection_name,
|
|
343
|
+
"model_name": model.__name__,
|
|
344
|
+
"fields": fields,
|
|
345
|
+
"total_fields": len(fields),
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@router.get("/collections", response_class=HTMLResponse)
|
|
350
|
+
def debug_collections(request: Request):
|
|
351
|
+
"""Debug endpoint to display MongoDB collection schemas."""
|
|
352
|
+
collections_info = [_get_collection_info(model) for model in get_all_models()]
|
|
353
|
+
|
|
354
|
+
return render_template(
|
|
355
|
+
"debug/collections.html.jinja", request, {"collections": collections_info}
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@router.get("/users", response_class=HTMLResponse)
|
|
360
|
+
async def debug_users(request: Request):
|
|
361
|
+
"""Debug endpoint to list and impersonate users."""
|
|
362
|
+
|
|
363
|
+
users = await UserModel.find_all().to_list()
|
|
364
|
+
current_user_id = (
|
|
365
|
+
request.session.get("user", {}).get("id")
|
|
366
|
+
if isinstance(request.session.get("user"), dict)
|
|
367
|
+
else request.session.get("user")
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
return render_template(
|
|
371
|
+
"debug/users.html.jinja",
|
|
372
|
+
request,
|
|
373
|
+
{"users": users, "current_user_id": current_user_id},
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@router.post("/impersonate/{user_id}")
|
|
378
|
+
async def debug_impersonate_user(request: Request, user_id: str):
|
|
379
|
+
"""Impersonate a user by setting their ID in the session."""
|
|
380
|
+
# Double check debug mode for security
|
|
381
|
+
if not ctx.DEBUG:
|
|
382
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
383
|
+
|
|
384
|
+
# Verify user exists
|
|
385
|
+
user = await UserModel.get(user_id)
|
|
386
|
+
if not user:
|
|
387
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
388
|
+
|
|
389
|
+
# Set full user session data (using the proper session_dict method)
|
|
390
|
+
request.session["user"] = user.session_dict
|
|
391
|
+
|
|
392
|
+
return RedirectResponse(url="/", status_code=302)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@router.post("/stop-impersonation")
|
|
396
|
+
async def debug_stop_impersonation(request: Request):
|
|
397
|
+
"""Stop impersonating and clear user session."""
|
|
398
|
+
# Double check debug mode for security
|
|
399
|
+
if not ctx.DEBUG:
|
|
400
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
401
|
+
|
|
402
|
+
request.session.pop("user", None)
|
|
403
|
+
return RedirectResponse(url="/debug/users", status_code=302)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@router.get("/clear-session")
|
|
407
|
+
async def debug_clear_session(request: Request):
|
|
408
|
+
"""Clear all session data to fix corrupted sessions."""
|
|
409
|
+
# Double check debug mode for security
|
|
410
|
+
if not ctx.DEBUG:
|
|
411
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
412
|
+
|
|
413
|
+
request.session.clear()
|
|
414
|
+
return RedirectResponse(url="/debug/users?cleared=1", status_code=302)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter
|
|
5
|
+
|
|
6
|
+
from vibetuner.config import settings
|
|
7
|
+
from vibetuner.paths import root as root_path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/health")
|
|
11
|
+
|
|
12
|
+
# Store startup time for instance identification
|
|
13
|
+
_startup_time = datetime.now()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/ping")
|
|
17
|
+
def health_ping():
|
|
18
|
+
"""Simple health check endpoint"""
|
|
19
|
+
return {"ping": "ok"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("/id")
|
|
23
|
+
def health_instance_id():
|
|
24
|
+
"""Instance identification endpoint for distinguishing app instances"""
|
|
25
|
+
if root_path is None:
|
|
26
|
+
raise RuntimeError(
|
|
27
|
+
"Project root not detected. Cannot provide instance information."
|
|
28
|
+
)
|
|
29
|
+
return {
|
|
30
|
+
"app": settings.project.project_slug,
|
|
31
|
+
"port": int(os.environ.get("PORT", 8000)),
|
|
32
|
+
"debug": settings.debug,
|
|
33
|
+
"status": "healthy",
|
|
34
|
+
"root_path": str(root_path.resolve()),
|
|
35
|
+
"process_id": os.getpid(),
|
|
36
|
+
"startup_time": _startup_time.isoformat(),
|
|
37
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from babel import Locale
|
|
2
|
+
from fastapi import APIRouter, Depends, Request
|
|
3
|
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
4
|
+
|
|
5
|
+
from ..deps import require_htmx
|
|
6
|
+
from ..lifespan import ctx
|
|
7
|
+
from ..templates import render_template
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
LOCALE_NAMES: dict[str, str] = dict(
|
|
13
|
+
sorted(
|
|
14
|
+
{
|
|
15
|
+
locale: (Locale.parse(locale).display_name or locale).capitalize()
|
|
16
|
+
for locale in ctx.supported_languages
|
|
17
|
+
}.items(),
|
|
18
|
+
key=lambda x: x[1],
|
|
19
|
+
),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/set-language/{lang}")
|
|
24
|
+
async def set_language(request: Request, lang: str, current: str) -> RedirectResponse:
|
|
25
|
+
new_url = f"/{lang}{current[3:]}" if current else request.url_for("homepage").path
|
|
26
|
+
response = RedirectResponse(url=new_url)
|
|
27
|
+
response.set_cookie(key="language", value=lang, max_age=3600)
|
|
28
|
+
|
|
29
|
+
return response
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("/get-languages", dependencies=[Depends(require_htmx)])
|
|
33
|
+
async def get_languages(request: Request) -> HTMLResponse:
|
|
34
|
+
"""Return a list of supported languages."""
|
|
35
|
+
|
|
36
|
+
return render_template(
|
|
37
|
+
"lang/select.html.jinja",
|
|
38
|
+
request=request,
|
|
39
|
+
ctx={
|
|
40
|
+
"locale_names": LOCALE_NAMES,
|
|
41
|
+
"current_language": request.state.language,
|
|
42
|
+
},
|
|
43
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Request
|
|
4
|
+
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
|
|
5
|
+
|
|
6
|
+
from vibetuner.paths import fallback_static_default
|
|
7
|
+
|
|
8
|
+
from ..templates import render_template
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
router = APIRouter()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Favicon Related Routes
|
|
15
|
+
# Todo, provide an easy way to override default statics
|
|
16
|
+
@router.get("/favicon.ico", response_class=FileResponse)
|
|
17
|
+
async def favicon() -> Path:
|
|
18
|
+
return fallback_static_default("favicons", "favicon.ico")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Misc static routes
|
|
22
|
+
@router.get("/robots.txt", response_class=PlainTextResponse)
|
|
23
|
+
def robots(request: Request) -> HTMLResponse:
|
|
24
|
+
return render_template(
|
|
25
|
+
"meta/robots.txt.jinja",
|
|
26
|
+
request=request,
|
|
27
|
+
media_type="text/plain",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.get("/sitemap.xml")
|
|
32
|
+
async def sitemap(request: Request) -> HTMLResponse:
|
|
33
|
+
return render_template(
|
|
34
|
+
"meta/sitemap.xml.jinja",
|
|
35
|
+
request,
|
|
36
|
+
media_type="application/xml",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/site.webmanifest")
|
|
41
|
+
async def site_webmanifest(request: Request) -> HTMLResponse:
|
|
42
|
+
return render_template(
|
|
43
|
+
"meta/site.webmanifest.jinja",
|
|
44
|
+
request,
|
|
45
|
+
media_type="application/manifest+json",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.get("/browserconfig.xml")
|
|
50
|
+
async def browserconfig(request: Request) -> HTMLResponse:
|
|
51
|
+
return render_template(
|
|
52
|
+
"meta/browserconfig.xml.jinja",
|
|
53
|
+
request,
|
|
54
|
+
media_type="application/xml",
|
|
55
|
+
)
|