ohmyapi 0.1.19__tar.gz → 0.1.21__tar.gz
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.
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/PKG-INFO +21 -13
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/README.md +20 -12
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/pyproject.toml +23 -1
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/__init__.py +0 -1
- ohmyapi-0.1.21/src/ohmyapi/builtin/auth/__init__.py +1 -0
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/builtin/auth/models.py +12 -7
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/builtin/auth/routes.py +52 -28
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/cli.py +42 -38
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/runtime.py +56 -24
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/scaffolding.py +22 -6
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/templates/app/routes.py.j2 +1 -1
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/db/exceptions.py +0 -1
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/db/model/model.py +27 -2
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/router.py +0 -1
- ohmyapi-0.1.19/src/ohmyapi/builtin/auth/__init__.py +0 -4
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/__main__.py +0 -0
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/builtin/auth/permissions.py +3 -3
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/__init__.py +0 -0
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/templates/app/__init__.py.j2 +0 -0
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/templates/app/models.py.j2 +0 -0
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/templates/project/README.md.j2 +0 -0
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/templates/project/pyproject.toml.j2 +0 -0
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/core/templates/project/settings.py.j2 +0 -0
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/db/__init__.py +3 -3
- {ohmyapi-0.1.19 → ohmyapi-0.1.21}/src/ohmyapi/db/model/__init__.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: ohmyapi
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.21
|
4
4
|
Summary: A Django-like but async web-framework based on FastAPI and TortoiseORM.
|
5
5
|
License-Expression: MIT
|
6
6
|
Keywords: fastapi,tortoise,orm,async,web-framework
|
@@ -118,31 +118,35 @@ Write your first model in `turnament/models.py`:
|
|
118
118
|
```python
|
119
119
|
from ohmyapi.db import Model, field
|
120
120
|
|
121
|
+
from datetime import datetime
|
122
|
+
from decimal import Decimal
|
123
|
+
from uuid import UUID
|
124
|
+
|
121
125
|
|
122
126
|
class Tournament(Model):
|
123
|
-
id = field.data.UUIDField(primary_key=True)
|
124
|
-
name = field.TextField()
|
125
|
-
created = field.DatetimeField(auto_now_add=True)
|
127
|
+
id: UUID = field.data.UUIDField(primary_key=True)
|
128
|
+
name: str = field.TextField()
|
129
|
+
created: datetime = field.DatetimeField(auto_now_add=True)
|
126
130
|
|
127
131
|
def __str__(self):
|
128
132
|
return self.name
|
129
133
|
|
130
134
|
|
131
135
|
class Event(Model):
|
132
|
-
id = field.data.UUIDField(primary_key=True)
|
133
|
-
name = field.TextField()
|
134
|
-
tournament = field.ForeignKeyField('tournament.Tournament', related_name='events')
|
135
|
-
participants = field.ManyToManyField('tournament.Team', related_name='events', through='event_team')
|
136
|
-
modified = field.DatetimeField(auto_now=True)
|
137
|
-
prize = field.DecimalField(max_digits=10, decimal_places=2, null=True)
|
136
|
+
id: UUID = field.data.UUIDField(primary_key=True)
|
137
|
+
name: str = field.TextField()
|
138
|
+
tournament: UUID = field.ForeignKeyField('tournament.Tournament', related_name='events')
|
139
|
+
participants: field.ManyToManyRelation[Team] = field.ManyToManyField('tournament.Team', related_name='events', through='event_team')
|
140
|
+
modified: datetime = field.DatetimeField(auto_now=True)
|
141
|
+
prize: Decimal = field.DecimalField(max_digits=10, decimal_places=2, null=True)
|
138
142
|
|
139
143
|
def __str__(self):
|
140
144
|
return self.name
|
141
145
|
|
142
146
|
|
143
147
|
class Team(Model):
|
144
|
-
id = field.data.UUIDField(primary_key=True)
|
145
|
-
name = field.TextField()
|
148
|
+
id: UUID = field.data.UUIDField(primary_key=True)
|
149
|
+
name: str = field.TextField()
|
146
150
|
|
147
151
|
def __str__(self):
|
148
152
|
return self.name
|
@@ -242,9 +246,13 @@ It comes with a `User` and `Group` model, as well as endpoints for JWT auth.
|
|
242
246
|
You can use the models as `ForeignKeyField` in your application models:
|
243
247
|
|
244
248
|
```python
|
249
|
+
from ohmyapi.db import Model, field
|
250
|
+
from ohmyapi_auth.models import User
|
251
|
+
|
252
|
+
|
245
253
|
class Team(Model):
|
246
254
|
[...]
|
247
|
-
members = field.ManyToManyField('ohmyapi_auth.User', related_name='tournament_teams', through='tournament_teams')
|
255
|
+
members: field.ManyToManyRelation[User] = field.ManyToManyField('ohmyapi_auth.User', related_name='tournament_teams', through='tournament_teams')
|
248
256
|
[...]
|
249
257
|
```
|
250
258
|
|
@@ -86,31 +86,35 @@ Write your first model in `turnament/models.py`:
|
|
86
86
|
```python
|
87
87
|
from ohmyapi.db import Model, field
|
88
88
|
|
89
|
+
from datetime import datetime
|
90
|
+
from decimal import Decimal
|
91
|
+
from uuid import UUID
|
92
|
+
|
89
93
|
|
90
94
|
class Tournament(Model):
|
91
|
-
id = field.data.UUIDField(primary_key=True)
|
92
|
-
name = field.TextField()
|
93
|
-
created = field.DatetimeField(auto_now_add=True)
|
95
|
+
id: UUID = field.data.UUIDField(primary_key=True)
|
96
|
+
name: str = field.TextField()
|
97
|
+
created: datetime = field.DatetimeField(auto_now_add=True)
|
94
98
|
|
95
99
|
def __str__(self):
|
96
100
|
return self.name
|
97
101
|
|
98
102
|
|
99
103
|
class Event(Model):
|
100
|
-
id = field.data.UUIDField(primary_key=True)
|
101
|
-
name = field.TextField()
|
102
|
-
tournament = field.ForeignKeyField('tournament.Tournament', related_name='events')
|
103
|
-
participants = field.ManyToManyField('tournament.Team', related_name='events', through='event_team')
|
104
|
-
modified = field.DatetimeField(auto_now=True)
|
105
|
-
prize = field.DecimalField(max_digits=10, decimal_places=2, null=True)
|
104
|
+
id: UUID = field.data.UUIDField(primary_key=True)
|
105
|
+
name: str = field.TextField()
|
106
|
+
tournament: UUID = field.ForeignKeyField('tournament.Tournament', related_name='events')
|
107
|
+
participants: field.ManyToManyRelation[Team] = field.ManyToManyField('tournament.Team', related_name='events', through='event_team')
|
108
|
+
modified: datetime = field.DatetimeField(auto_now=True)
|
109
|
+
prize: Decimal = field.DecimalField(max_digits=10, decimal_places=2, null=True)
|
106
110
|
|
107
111
|
def __str__(self):
|
108
112
|
return self.name
|
109
113
|
|
110
114
|
|
111
115
|
class Team(Model):
|
112
|
-
id = field.data.UUIDField(primary_key=True)
|
113
|
-
name = field.TextField()
|
116
|
+
id: UUID = field.data.UUIDField(primary_key=True)
|
117
|
+
name: str = field.TextField()
|
114
118
|
|
115
119
|
def __str__(self):
|
116
120
|
return self.name
|
@@ -210,9 +214,13 @@ It comes with a `User` and `Group` model, as well as endpoints for JWT auth.
|
|
210
214
|
You can use the models as `ForeignKeyField` in your application models:
|
211
215
|
|
212
216
|
```python
|
217
|
+
from ohmyapi.db import Model, field
|
218
|
+
from ohmyapi_auth.models import User
|
219
|
+
|
220
|
+
|
213
221
|
class Team(Model):
|
214
222
|
[...]
|
215
|
-
members = field.ManyToManyField('ohmyapi_auth.User', related_name='tournament_teams', through='tournament_teams')
|
223
|
+
members: field.ManyToManyRelation[User] = field.ManyToManyField('ohmyapi_auth.User', related_name='tournament_teams', through='tournament_teams')
|
216
224
|
[...]
|
217
225
|
```
|
218
226
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "ohmyapi"
|
3
|
-
version = "0.1.
|
3
|
+
version = "0.1.21"
|
4
4
|
description = "A Django-like but async web-framework based on FastAPI and TortoiseORM."
|
5
5
|
license = "MIT"
|
6
6
|
keywords = ["fastapi", "tortoise", "orm", "async", "web-framework"]
|
@@ -27,6 +27,8 @@ dependencies = [
|
|
27
27
|
|
28
28
|
[tool.poetry.group.dev.dependencies]
|
29
29
|
ipython = ">=9.5.0,<10.0.0"
|
30
|
+
black = "^25.9.0"
|
31
|
+
isort = "^6.0.1"
|
30
32
|
|
31
33
|
[project.optional-dependencies]
|
32
34
|
auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"]
|
@@ -36,3 +38,23 @@ packages = [ { include = "ohmyapi", from = "src" } ]
|
|
36
38
|
|
37
39
|
[project.scripts]
|
38
40
|
ohmyapi = "ohmyapi.cli:app"
|
41
|
+
|
42
|
+
[tool.black]
|
43
|
+
line-length = 88
|
44
|
+
target-version = ['py39', 'py310', 'py311', 'py312', 'py313']
|
45
|
+
include = '\.pyi?$'
|
46
|
+
exclude = '''
|
47
|
+
/(
|
48
|
+
\.git
|
49
|
+
| \.venv
|
50
|
+
| build
|
51
|
+
| dist
|
52
|
+
)/
|
53
|
+
'''
|
54
|
+
|
55
|
+
[tool.isort]
|
56
|
+
profile = "black" # makes imports compatible with black
|
57
|
+
line_length = 88
|
58
|
+
multi_line_output = 3
|
59
|
+
include_trailing_comma = true
|
60
|
+
|
@@ -0,0 +1 @@
|
|
1
|
+
from . import models, permissions, routes
|
@@ -1,29 +1,34 @@
|
|
1
1
|
from functools import wraps
|
2
|
-
from typing import
|
3
|
-
from
|
4
|
-
|
2
|
+
from typing import List, Optional
|
3
|
+
from uuid import UUID
|
4
|
+
|
5
5
|
from passlib.context import CryptContext
|
6
6
|
from tortoise.contrib.pydantic import pydantic_queryset_creator
|
7
7
|
|
8
|
+
from ohmyapi.db import Model, field, pre_delete, pre_save
|
9
|
+
from ohmyapi.router import HTTPException
|
10
|
+
|
8
11
|
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
9
12
|
|
10
13
|
|
11
14
|
class Group(Model):
|
12
|
-
id:
|
15
|
+
id: UUID = field.data.UUIDField(pk=True)
|
13
16
|
name: str = field.CharField(max_length=42, index=True)
|
14
17
|
|
15
18
|
|
16
19
|
class User(Model):
|
17
|
-
id:
|
20
|
+
id: UUID = field.data.UUIDField(pk=True)
|
18
21
|
email: str = field.CharField(max_length=255, unique=True, index=True)
|
19
22
|
username: str = field.CharField(max_length=150, unique=True)
|
20
23
|
password_hash: str = field.CharField(max_length=128)
|
21
24
|
is_admin: bool = field.BooleanField(default=False)
|
22
25
|
is_staff: bool = field.BooleanField(default=False)
|
23
|
-
groups: field.ManyToManyRelation[Group] = field.ManyToManyField(
|
26
|
+
groups: field.ManyToManyRelation[Group] = field.ManyToManyField(
|
27
|
+
"ohmyapi_auth.Group", related_name="users", through="user_groups"
|
28
|
+
)
|
24
29
|
|
25
30
|
class Schema:
|
26
|
-
exclude =
|
31
|
+
exclude = ("password_hash",)
|
27
32
|
|
28
33
|
def set_password(self, raw_password: str) -> None:
|
29
34
|
"""Hash and store the password."""
|
@@ -3,13 +3,12 @@ from enum import Enum
|
|
3
3
|
from typing import Any, Dict, List
|
4
4
|
|
5
5
|
import jwt
|
6
|
+
import settings
|
6
7
|
from fastapi import APIRouter, Body, Depends, Header, HTTPException, status
|
7
8
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
8
9
|
from pydantic import BaseModel
|
9
10
|
|
10
|
-
from ohmyapi.builtin.auth.models import
|
11
|
-
|
12
|
-
import settings
|
11
|
+
from ohmyapi.builtin.auth.models import Group, User
|
13
12
|
|
14
13
|
# Router
|
15
14
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
@@ -17,8 +16,12 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
|
17
16
|
# Secrets & config (should come from settings/env in real projects)
|
18
17
|
JWT_SECRET = getattr(settings, "JWT_SECRET", "changeme")
|
19
18
|
JWT_ALGORITHM = getattr(settings, "JWT_ALGORITHM", "HS256")
|
20
|
-
ACCESS_TOKEN_EXPIRE_SECONDS = getattr(
|
21
|
-
|
19
|
+
ACCESS_TOKEN_EXPIRE_SECONDS = getattr(
|
20
|
+
settings, "JWT_ACCESS_TOKEN_EXPIRE_SECONDS", 15 * 60
|
21
|
+
)
|
22
|
+
REFRESH_TOKEN_EXPIRE_SECONDS = getattr(
|
23
|
+
settings, "JWT_REFRESH_TOKEN_EXPIRE_SECONDS", 7 * 24 * 60 * 60
|
24
|
+
)
|
22
25
|
|
23
26
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
24
27
|
|
@@ -36,30 +39,38 @@ def decode_token(token: str) -> Dict:
|
|
36
39
|
try:
|
37
40
|
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
38
41
|
except jwt.ExpiredSignatureError:
|
39
|
-
raise HTTPException(
|
42
|
+
raise HTTPException(
|
43
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
|
44
|
+
)
|
40
45
|
except jwt.InvalidTokenError:
|
41
|
-
raise HTTPException(
|
46
|
+
raise HTTPException(
|
47
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
|
48
|
+
)
|
42
49
|
|
43
50
|
|
44
51
|
class TokenType(str, Enum):
|
45
52
|
"""
|
46
53
|
Helper for indicating the token type when generating claims.
|
47
54
|
"""
|
55
|
+
|
48
56
|
access = "access"
|
49
57
|
refresh = "refresh"
|
50
58
|
|
51
59
|
|
52
|
-
def claims(
|
60
|
+
def claims(
|
61
|
+
token_type: TokenType, user: User, groups: List[Group] = []
|
62
|
+
) -> Dict[str, Any]:
|
53
63
|
return {
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
64
|
+
"type": token_type,
|
65
|
+
"sub": str(user.id),
|
66
|
+
"user": {
|
67
|
+
"username": user.username,
|
68
|
+
"email": user.email,
|
59
69
|
},
|
60
|
-
|
70
|
+
"roles": [g.name for g in groups],
|
61
71
|
}
|
62
72
|
|
73
|
+
|
63
74
|
async def get_token(token: str = Depends(oauth2_scheme)) -> Dict:
|
64
75
|
"""Dependency: token introspection"""
|
65
76
|
payload = decode_token(token)
|
@@ -71,11 +82,15 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
|
|
71
82
|
payload = decode_token(token)
|
72
83
|
user_id = payload.get("sub")
|
73
84
|
if user_id is None:
|
74
|
-
raise HTTPException(
|
85
|
+
raise HTTPException(
|
86
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload"
|
87
|
+
)
|
75
88
|
|
76
89
|
user = await User.filter(id=user_id).first()
|
77
90
|
if not user:
|
78
|
-
raise HTTPException(
|
91
|
+
raise HTTPException(
|
92
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
|
93
|
+
)
|
79
94
|
return user
|
80
95
|
|
81
96
|
|
@@ -101,15 +116,13 @@ async def require_staff(current_user: User = Depends(get_current_user)) -> User:
|
|
101
116
|
|
102
117
|
|
103
118
|
async def require_group(
|
104
|
-
group_name: str,
|
105
|
-
current_user: User = Depends(get_current_user)
|
119
|
+
group_name: str, current_user: User = Depends(get_current_user)
|
106
120
|
) -> User:
|
107
121
|
"""Ensure the current user belongs to the given group."""
|
108
122
|
user_groups = await current_user.groups.all()
|
109
123
|
if not any(g.name == group_name for g in user_groups):
|
110
124
|
raise HTTPException(
|
111
|
-
status_code=403,
|
112
|
-
detail=f"User must belong to group '{group_name}'"
|
125
|
+
status_code=403, detail=f"User must belong to group '{group_name}'"
|
113
126
|
)
|
114
127
|
return current_user
|
115
128
|
|
@@ -124,15 +137,21 @@ async def login(form_data: LoginRequest = Body(...)):
|
|
124
137
|
"""Login with username & password, returns access and refresh tokens."""
|
125
138
|
user = await User.authenticate(form_data.username, form_data.password)
|
126
139
|
if not user:
|
127
|
-
raise HTTPException(
|
140
|
+
raise HTTPException(
|
141
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
|
142
|
+
)
|
128
143
|
|
129
|
-
access_token = create_token(
|
130
|
-
|
144
|
+
access_token = create_token(
|
145
|
+
claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS
|
146
|
+
)
|
147
|
+
refresh_token = create_token(
|
148
|
+
claims(TokenType.refresh, user), REFRESH_TOKEN_EXPIRE_SECONDS
|
149
|
+
)
|
131
150
|
|
132
151
|
return {
|
133
152
|
"access_token": access_token,
|
134
153
|
"refresh_token": refresh_token,
|
135
|
-
"token_type": "bearer"
|
154
|
+
"token_type": "bearer",
|
136
155
|
}
|
137
156
|
|
138
157
|
|
@@ -141,14 +160,20 @@ async def refresh_token(refresh_token: str):
|
|
141
160
|
"""Exchange refresh token for new access token."""
|
142
161
|
payload = decode_token(refresh_token)
|
143
162
|
if payload.get("type") != "refresh":
|
144
|
-
raise HTTPException(
|
163
|
+
raise HTTPException(
|
164
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
|
165
|
+
)
|
145
166
|
|
146
167
|
user_id = payload.get("sub")
|
147
168
|
user = await User.filter(id=user_id).first()
|
148
169
|
if not user:
|
149
|
-
raise HTTPException(
|
170
|
+
raise HTTPException(
|
171
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
|
172
|
+
)
|
150
173
|
|
151
|
-
new_access = create_token(
|
174
|
+
new_access = create_token(
|
175
|
+
claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS
|
176
|
+
)
|
152
177
|
return {"access_token": new_access, "token_type": "bearer"}
|
153
178
|
|
154
179
|
|
@@ -161,4 +186,3 @@ async def introspect(token: Dict = Depends(get_token)):
|
|
161
186
|
async def me(user: User = Depends(get_current_user)):
|
162
187
|
"""Return the currently authenticated user."""
|
163
188
|
return User.Schema.one.from_orm(user)
|
164
|
-
|
@@ -2,17 +2,17 @@ import asyncio
|
|
2
2
|
import atexit
|
3
3
|
import importlib
|
4
4
|
import sys
|
5
|
+
from getpass import getpass
|
6
|
+
from pathlib import Path
|
7
|
+
|
5
8
|
import typer
|
6
9
|
import uvicorn
|
7
10
|
|
8
|
-
from
|
9
|
-
from ohmyapi.core import scaffolding, runtime
|
10
|
-
from pathlib import Path
|
11
|
+
from ohmyapi.core import runtime, scaffolding
|
11
12
|
|
12
|
-
app = typer.Typer(
|
13
|
-
|
14
|
-
|
15
|
-
"""
|
13
|
+
app = typer.Typer(
|
14
|
+
help="OhMyAPI — Django-flavored FastAPI scaffolding with tightly integrated TortoiseORM."
|
15
|
+
)
|
16
16
|
|
17
17
|
|
18
18
|
@app.command()
|
@@ -40,52 +40,51 @@ def serve(root: str = ".", host="127.0.0.1", port=8000):
|
|
40
40
|
|
41
41
|
@app.command()
|
42
42
|
def shell(root: str = "."):
|
43
|
-
"""
|
44
|
-
Launch an interactive IPython shell with the project and apps loaded.
|
45
|
-
"""
|
46
43
|
project_path = Path(root).resolve()
|
47
44
|
project = runtime.Project(project_path)
|
48
45
|
|
49
|
-
|
50
|
-
|
46
|
+
banner = f"""
|
47
|
+
OhMyAPI Project Shell: {getattr(project.settings, 'PROJECT_NAME', 'MyProject')}
|
48
|
+
Find your loaded project singleton via identifier: `p`; i.e.: `p.apps`
|
49
|
+
"""
|
50
|
+
|
51
|
+
async def init_and_cleanup():
|
52
|
+
try:
|
53
|
+
await project.init_orm()
|
54
|
+
return True
|
55
|
+
except Exception as e:
|
56
|
+
print(f"Failed to initialize ORM: {e}")
|
57
|
+
return False
|
58
|
+
|
59
|
+
async def cleanup():
|
51
60
|
try:
|
52
61
|
await project.close_orm()
|
53
62
|
print("Tortoise ORM closed successfully.")
|
54
63
|
except Exception as e:
|
55
64
|
print(f"Error closing ORM: {e}")
|
56
65
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
if loop and loop.is_running():
|
64
|
-
asyncio.create_task(close_project())
|
65
|
-
else:
|
66
|
-
asyncio.run(close_project())
|
67
|
-
|
68
|
-
# Ensure the ORM is initialized
|
69
|
-
asyncio.run(project.init_orm())
|
66
|
+
loop = asyncio.new_event_loop()
|
67
|
+
asyncio.set_event_loop(loop)
|
68
|
+
loop.run_until_complete(init_and_cleanup())
|
69
|
+
|
70
|
+
# Prepare shell vars that are to be directly available
|
71
|
+
shell_vars = {"p": project}
|
70
72
|
|
71
73
|
try:
|
72
74
|
from IPython import start_ipython
|
73
|
-
shell_vars = {
|
74
|
-
"p": project,
|
75
|
-
}
|
76
75
|
from traitlets.config.loader import Config
|
76
|
+
|
77
77
|
c = Config()
|
78
78
|
c.TerminalIPythonApp.display_banner = True
|
79
|
-
c.TerminalInteractiveShell.banner2 = banner
|
80
|
-
|
81
|
-
})
|
82
|
-
atexit.register(cleanup)
|
79
|
+
c.TerminalInteractiveShell.banner2 = banner
|
80
|
+
|
83
81
|
start_ipython(argv=[], user_ns=shell_vars, config=c)
|
84
82
|
except ImportError:
|
85
|
-
typer.echo("IPython is not installed. Falling back to built-in Python shell.")
|
86
83
|
import code
|
87
|
-
|
88
|
-
code.interact(local=
|
84
|
+
|
85
|
+
code.interact(local=shell_vars, banner=banner)
|
86
|
+
finally:
|
87
|
+
loop.run_until_complete(cleanup())
|
89
88
|
|
90
89
|
|
91
90
|
@app.command()
|
@@ -125,11 +124,15 @@ def createsuperuser(root: str = "."):
|
|
125
124
|
project_path = Path(root).resolve()
|
126
125
|
project = runtime.Project(project_path)
|
127
126
|
if not project.is_app_installed("ohmyapi_auth"):
|
128
|
-
print(
|
127
|
+
print(
|
128
|
+
"Auth app not installed! Please add 'ohmyapi_auth' to your INSTALLED_APPS."
|
129
|
+
)
|
129
130
|
return
|
130
131
|
|
131
132
|
import asyncio
|
133
|
+
|
132
134
|
import ohmyapi_auth
|
135
|
+
|
133
136
|
email = input("E-Mail: ")
|
134
137
|
username = input("Username: ")
|
135
138
|
password1, password2 = "foo", "bar"
|
@@ -138,9 +141,10 @@ def createsuperuser(root: str = "."):
|
|
138
141
|
password2 = getpass("Repeat Password: ")
|
139
142
|
if password1 != password2:
|
140
143
|
print("Passwords didn't match!")
|
141
|
-
user = ohmyapi_auth.models.User(
|
144
|
+
user = ohmyapi_auth.models.User(
|
145
|
+
email=email, username=username, is_staff=True, is_admin=True
|
146
|
+
)
|
142
147
|
user.set_password(password1)
|
143
148
|
asyncio.run(project.init_orm())
|
144
149
|
asyncio.run(user.save())
|
145
150
|
asyncio.run(project.close_orm())
|
146
|
-
|
@@ -2,16 +2,18 @@
|
|
2
2
|
import copy
|
3
3
|
import importlib
|
4
4
|
import importlib.util
|
5
|
+
import json
|
5
6
|
import pkgutil
|
6
7
|
import sys
|
7
8
|
from pathlib import Path
|
8
|
-
from typing import Dict, List, Optional
|
9
|
+
from typing import Any, Dict, Generator, List, Optional
|
9
10
|
|
10
11
|
import click
|
11
12
|
from aerich import Command as AerichCommand
|
12
13
|
from aerich.exceptions import NotInitedError
|
14
|
+
from fastapi import APIRouter, FastAPI
|
13
15
|
from tortoise import Tortoise
|
14
|
-
|
16
|
+
|
15
17
|
from ohmyapi.db.model import Model
|
16
18
|
|
17
19
|
|
@@ -43,7 +45,9 @@ class Project:
|
|
43
45
|
orig = importlib.import_module(full)
|
44
46
|
sys.modules[alias] = orig
|
45
47
|
try:
|
46
|
-
sys.modules[f"{alias}.models"] = importlib.import_module(
|
48
|
+
sys.modules[f"{alias}.models"] = importlib.import_module(
|
49
|
+
f"{full}.models"
|
50
|
+
)
|
47
51
|
except ModuleNotFoundError:
|
48
52
|
pass
|
49
53
|
|
@@ -51,7 +55,9 @@ class Project:
|
|
51
55
|
try:
|
52
56
|
self.settings = importlib.import_module("settings")
|
53
57
|
except Exception as e:
|
54
|
-
raise RuntimeError(
|
58
|
+
raise RuntimeError(
|
59
|
+
f"Failed to import project settings from {self.project_path}"
|
60
|
+
) from e
|
55
61
|
|
56
62
|
# Load installed apps
|
57
63
|
for app_name in getattr(self.settings, "INSTALLED_APPS", []):
|
@@ -103,11 +109,16 @@ class Project:
|
|
103
109
|
for app_name, app in self._apps.items():
|
104
110
|
modules = list(dict.fromkeys(app.model_modules))
|
105
111
|
if modules:
|
106
|
-
config["apps"][app_name] = {
|
112
|
+
config["apps"][app_name] = {
|
113
|
+
"models": modules,
|
114
|
+
"default_connection": "default",
|
115
|
+
}
|
107
116
|
|
108
117
|
return config
|
109
118
|
|
110
|
-
def build_aerich_command(
|
119
|
+
def build_aerich_command(
|
120
|
+
self, app_label: str, db_url: Optional[str] = None
|
121
|
+
) -> AerichCommand:
|
111
122
|
# Resolve label to flat_label
|
112
123
|
if app_label in self._apps:
|
113
124
|
flat_label = app_label
|
@@ -128,7 +139,7 @@ class Project:
|
|
128
139
|
return AerichCommand(
|
129
140
|
tortoise_config=tortoise_cfg,
|
130
141
|
app=flat_label,
|
131
|
-
location=str(self.migrations_dir)
|
142
|
+
location=str(self.migrations_dir),
|
132
143
|
)
|
133
144
|
|
134
145
|
# --- ORM lifecycle ---
|
@@ -143,7 +154,9 @@ class Project:
|
|
143
154
|
await Tortoise.close_connections()
|
144
155
|
|
145
156
|
# --- Migration helpers ---
|
146
|
-
async def makemigrations(
|
157
|
+
async def makemigrations(
|
158
|
+
self, app_label: str, name: str = "auto", db_url: Optional[str] = None
|
159
|
+
) -> None:
|
147
160
|
cmd = self.build_aerich_command(app_label, db_url=db_url)
|
148
161
|
async with cmd as c:
|
149
162
|
await c.init()
|
@@ -157,7 +170,9 @@ class Project:
|
|
157
170
|
await c.init_db(safe=True)
|
158
171
|
await c.migrate(name=name)
|
159
172
|
|
160
|
-
async def migrate(
|
173
|
+
async def migrate(
|
174
|
+
self, app_label: Optional[str] = None, db_url: Optional[str] = None
|
175
|
+
) -> None:
|
161
176
|
labels: List[str]
|
162
177
|
if app_label:
|
163
178
|
if app_label in self._apps:
|
@@ -218,30 +233,47 @@ class App:
|
|
218
233
|
pass
|
219
234
|
|
220
235
|
def __repr__(self):
|
221
|
-
|
222
|
-
out += f"App: {self.name}\n"
|
223
|
-
out += f"Models:\n"
|
224
|
-
for model in self.models:
|
225
|
-
out += f" - {model.__name__}\n"
|
226
|
-
out += "Routes:\n"
|
227
|
-
for route in (self.routes or []):
|
228
|
-
out += f" - {route}\n"
|
229
|
-
return out
|
236
|
+
return json.dumps(self.dict(), indent=2)
|
230
237
|
|
231
238
|
def __str__(self):
|
232
239
|
return self.__repr__()
|
233
240
|
|
241
|
+
def _serialize_route(self, route):
|
242
|
+
"""Convert APIRoute to JSON-serializable dict."""
|
243
|
+
return {
|
244
|
+
"path": route.path,
|
245
|
+
"name": route.name,
|
246
|
+
"methods": list(route.methods),
|
247
|
+
"endpoint": route.endpoint.__name__, # just the function name
|
248
|
+
"response_model": (
|
249
|
+
getattr(route, "response_model", None).__name__
|
250
|
+
if getattr(route, "response_model", None)
|
251
|
+
else None
|
252
|
+
),
|
253
|
+
"tags": getattr(route, "tags", None),
|
254
|
+
}
|
255
|
+
|
256
|
+
def _serialize_router(self):
|
257
|
+
return [self._serialize_route(route) for route in self.routes]
|
258
|
+
|
259
|
+
def dict(self) -> Dict[str, Any]:
|
260
|
+
return {
|
261
|
+
"models": [m.__name__ for m in self.models],
|
262
|
+
"routes": self._serialize_router(),
|
263
|
+
}
|
264
|
+
|
234
265
|
@property
|
235
|
-
def models(self) ->
|
236
|
-
models: List[Model] = []
|
266
|
+
def models(self) -> Generator[Model, None, None]:
|
237
267
|
for mod in self.model_modules:
|
238
268
|
models_mod = importlib.import_module(mod)
|
239
269
|
for obj in models_mod.__dict__.values():
|
240
|
-
if
|
241
|
-
|
242
|
-
|
270
|
+
if (
|
271
|
+
isinstance(obj, type)
|
272
|
+
and getattr(obj, "_meta", None) is not None
|
273
|
+
and obj.__name__ != "Model"
|
274
|
+
):
|
275
|
+
yield obj
|
243
276
|
|
244
277
|
@property
|
245
278
|
def routes(self):
|
246
279
|
return self.router.routes
|
247
|
-
|
@@ -1,4 +1,5 @@
|
|
1
1
|
from pathlib import Path
|
2
|
+
|
2
3
|
from jinja2 import Environment, FileSystemLoader
|
3
4
|
|
4
5
|
# Base templates directory
|
@@ -8,14 +9,21 @@ env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
|
|
8
9
|
|
9
10
|
def render_template_file(template_path: Path, context: dict, output_path: Path):
|
10
11
|
"""Render a single Jinja2 template file to disk."""
|
11
|
-
template = env.get_template(
|
12
|
+
template = env.get_template(
|
13
|
+
str(template_path.relative_to(TEMPLATE_DIR)).replace("\\", "/")
|
14
|
+
)
|
12
15
|
content = template.render(**context)
|
13
16
|
output_path.parent.mkdir(exist_ok=True)
|
14
17
|
with open(output_path, "w", encoding="utf-8") as f:
|
15
18
|
f.write(content)
|
16
19
|
|
17
20
|
|
18
|
-
def render_template_dir(
|
21
|
+
def render_template_dir(
|
22
|
+
template_subdir: str,
|
23
|
+
target_dir: Path,
|
24
|
+
context: dict,
|
25
|
+
subdir_name: str | None = None,
|
26
|
+
):
|
19
27
|
"""
|
20
28
|
Recursively render all *.j2 templates from TEMPLATE_DIR/template_subdir into target_dir.
|
21
29
|
If subdir_name is given, files are placed inside target_dir/subdir_name.
|
@@ -23,14 +31,18 @@ def render_template_dir(template_subdir: str, target_dir: Path, context: dict, s
|
|
23
31
|
template_dir = TEMPLATE_DIR / template_subdir
|
24
32
|
for root, _, files in template_dir.walk():
|
25
33
|
root_path = Path(root)
|
26
|
-
rel_root = root_path.relative_to(
|
34
|
+
rel_root = root_path.relative_to(
|
35
|
+
template_dir
|
36
|
+
) # path relative to template_subdir
|
27
37
|
|
28
38
|
for f in files:
|
29
39
|
if not f.endswith(".j2"):
|
30
40
|
continue
|
31
41
|
|
32
42
|
template_rel_path = rel_root / f
|
33
|
-
output_rel_path = Path(*template_rel_path.parts).with_suffix(
|
43
|
+
output_rel_path = Path(*template_rel_path.parts).with_suffix(
|
44
|
+
""
|
45
|
+
) # remove .j2
|
34
46
|
|
35
47
|
# optionally wrap in subdir_name
|
36
48
|
if subdir_name:
|
@@ -54,7 +66,11 @@ def startapp(name: str, project: str):
|
|
54
66
|
"""Create a new app inside a project: templates go into <project_dir>/<name>/"""
|
55
67
|
target_dir = Path(project)
|
56
68
|
target_dir.mkdir(exist_ok=True)
|
57
|
-
render_template_dir(
|
69
|
+
render_template_dir(
|
70
|
+
"app",
|
71
|
+
target_dir,
|
72
|
+
{"project_name": target_dir.resolve().name, "app_name": name},
|
73
|
+
subdir_name=name,
|
74
|
+
)
|
58
75
|
print(f"✅ App '{name}' created in project '{target_dir}' successfully.")
|
59
76
|
print(f"🔧 Remember to add '{name}' to your INSTALLED_APPS!")
|
60
|
-
|
@@ -1,6 +1,32 @@
|
|
1
|
+
from uuid import UUID
|
2
|
+
|
3
|
+
from pydantic import GetCoreSchemaHandler
|
4
|
+
from pydantic_core import core_schema
|
1
5
|
from tortoise import fields as field
|
2
|
-
from tortoise.models import Model as TortoiseModel
|
3
6
|
from tortoise.contrib.pydantic import pydantic_model_creator, pydantic_queryset_creator
|
7
|
+
from tortoise.models import Model as TortoiseModel
|
8
|
+
|
9
|
+
|
10
|
+
def __uuid_schema_monkey_patch(cls, source_type, handler):
|
11
|
+
# Always treat UUID as string schema
|
12
|
+
return core_schema.no_info_after_validator_function(
|
13
|
+
# Accept UUID or str, always return UUID internally
|
14
|
+
lambda v: v if isinstance(v, UUID) else UUID(str(v)),
|
15
|
+
core_schema.union_schema(
|
16
|
+
[
|
17
|
+
core_schema.str_schema(),
|
18
|
+
core_schema.is_instance_schema(UUID),
|
19
|
+
]
|
20
|
+
),
|
21
|
+
# But when serializing, always str()
|
22
|
+
serialization=core_schema.plain_serializer_function_ser_schema(
|
23
|
+
str, when_used="always"
|
24
|
+
),
|
25
|
+
)
|
26
|
+
|
27
|
+
|
28
|
+
# Monkey-patch UUID
|
29
|
+
UUID.__get_pydantic_core_schema__ = classmethod(__uuid_schema_monkey_patch)
|
4
30
|
|
5
31
|
|
6
32
|
class ModelMeta(type(TortoiseModel)):
|
@@ -43,4 +69,3 @@ class Model(TortoiseModel, metaclass=ModelMeta):
|
|
43
69
|
class Schema:
|
44
70
|
include = None
|
45
71
|
exclude = None
|
46
|
-
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
@@ -1,10 +1,10 @@
|
|
1
|
-
from .model import Model, field
|
2
1
|
from tortoise.manager import Manager
|
3
2
|
from tortoise.queryset import QuerySet
|
4
3
|
from tortoise.signals import (
|
5
|
-
pre_delete,
|
6
4
|
post_delete,
|
7
|
-
pre_save,
|
8
5
|
post_save,
|
6
|
+
pre_delete,
|
7
|
+
pre_save,
|
9
8
|
)
|
10
9
|
|
10
|
+
from .model import Model, field
|
File without changes
|