vibetuner 2.26.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. vibetuner/__init__.py +2 -0
  2. vibetuner/__main__.py +4 -0
  3. vibetuner/cli/__init__.py +141 -0
  4. vibetuner/cli/run.py +160 -0
  5. vibetuner/cli/scaffold.py +187 -0
  6. vibetuner/config.py +143 -0
  7. vibetuner/context.py +28 -0
  8. vibetuner/frontend/__init__.py +107 -0
  9. vibetuner/frontend/deps.py +41 -0
  10. vibetuner/frontend/email.py +45 -0
  11. vibetuner/frontend/hotreload.py +13 -0
  12. vibetuner/frontend/lifespan.py +37 -0
  13. vibetuner/frontend/middleware.py +151 -0
  14. vibetuner/frontend/oauth.py +196 -0
  15. vibetuner/frontend/routes/__init__.py +12 -0
  16. vibetuner/frontend/routes/auth.py +156 -0
  17. vibetuner/frontend/routes/debug.py +414 -0
  18. vibetuner/frontend/routes/health.py +37 -0
  19. vibetuner/frontend/routes/language.py +43 -0
  20. vibetuner/frontend/routes/meta.py +55 -0
  21. vibetuner/frontend/routes/user.py +94 -0
  22. vibetuner/frontend/templates.py +176 -0
  23. vibetuner/logging.py +87 -0
  24. vibetuner/models/__init__.py +14 -0
  25. vibetuner/models/blob.py +89 -0
  26. vibetuner/models/email_verification.py +84 -0
  27. vibetuner/models/mixins.py +76 -0
  28. vibetuner/models/oauth.py +57 -0
  29. vibetuner/models/registry.py +15 -0
  30. vibetuner/models/types.py +16 -0
  31. vibetuner/models/user.py +91 -0
  32. vibetuner/mongo.py +33 -0
  33. vibetuner/paths.py +250 -0
  34. vibetuner/services/__init__.py +0 -0
  35. vibetuner/services/blob.py +175 -0
  36. vibetuner/services/email.py +50 -0
  37. vibetuner/tasks/__init__.py +0 -0
  38. vibetuner/tasks/lifespan.py +28 -0
  39. vibetuner/tasks/worker.py +15 -0
  40. vibetuner/templates/email/magic_link.html.jinja +17 -0
  41. vibetuner/templates/email/magic_link.txt.jinja +5 -0
  42. vibetuner/templates/frontend/base/favicons.html.jinja +1 -0
  43. vibetuner/templates/frontend/base/footer.html.jinja +3 -0
  44. vibetuner/templates/frontend/base/header.html.jinja +0 -0
  45. vibetuner/templates/frontend/base/opengraph.html.jinja +7 -0
  46. vibetuner/templates/frontend/base/skeleton.html.jinja +45 -0
  47. vibetuner/templates/frontend/debug/collections.html.jinja +105 -0
  48. vibetuner/templates/frontend/debug/components/debug_nav.html.jinja +55 -0
  49. vibetuner/templates/frontend/debug/index.html.jinja +85 -0
  50. vibetuner/templates/frontend/debug/info.html.jinja +258 -0
  51. vibetuner/templates/frontend/debug/users.html.jinja +139 -0
  52. vibetuner/templates/frontend/debug/version.html.jinja +55 -0
  53. vibetuner/templates/frontend/email/magic_link.txt.jinja +5 -0
  54. vibetuner/templates/frontend/email_sent.html.jinja +83 -0
  55. vibetuner/templates/frontend/index.html.jinja +20 -0
  56. vibetuner/templates/frontend/lang/select.html.jinja +4 -0
  57. vibetuner/templates/frontend/login.html.jinja +89 -0
  58. vibetuner/templates/frontend/meta/browserconfig.xml.jinja +10 -0
  59. vibetuner/templates/frontend/meta/robots.txt.jinja +3 -0
  60. vibetuner/templates/frontend/meta/site.webmanifest.jinja +7 -0
  61. vibetuner/templates/frontend/meta/sitemap.xml.jinja +6 -0
  62. vibetuner/templates/frontend/user/edit.html.jinja +86 -0
  63. vibetuner/templates/frontend/user/profile.html.jinja +157 -0
  64. vibetuner/templates/markdown/.placeholder +0 -0
  65. vibetuner/templates.py +146 -0
  66. vibetuner/time.py +57 -0
  67. vibetuner/versioning.py +12 -0
  68. vibetuner-2.26.6.dist-info/METADATA +241 -0
  69. vibetuner-2.26.6.dist-info/RECORD +71 -0
  70. vibetuner-2.26.6.dist-info/WHEEL +4 -0
  71. vibetuner-2.26.6.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,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
+ ]
@@ -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