🎉 Let's go!
This commit is contained in:
commit
a230f03a5a
24 changed files with 2563 additions and 0 deletions
176
.gitignore
vendored
Normal file
176
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||||
|
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||||
|
|
||||||
|
### Python ###
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/#use-with-ide
|
||||||
|
.pdm.toml
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
### Python Patch ###
|
||||||
|
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||||
|
poetry.toml
|
||||||
|
|
||||||
|
# ruff
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# LSP config files
|
||||||
|
pyrightconfig.json
|
||||||
|
|
||||||
|
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||||
154
README.md
Normal file
154
README.md
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# OhMyAPI
|
||||||
|
|
||||||
|
> OhMyAPI == Application scaffolding for FastAPI+TortoiseORM.
|
||||||
|
|
||||||
|
OhMyAPI is a blazingly fast, async Python web application framework with batteries included.
|
||||||
|
It is built around FastAPI and TortoiseORM and is thus 100% async.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
- Django-like project-layout and -structure
|
||||||
|
- Django-like settings.py
|
||||||
|
- Django-like models via TortoiseORM
|
||||||
|
- Django-like model.Meta class for model configuration
|
||||||
|
- Django-like advanced permissions system
|
||||||
|
- Django-like migrations (makemigrations & migrate) via Aerich
|
||||||
|
- Django-like CLI for interfacing with your projects (startproject, startapp, shell, serve, etc)
|
||||||
|
- various optional builtin apps
|
||||||
|
- highly configurable and customizable
|
||||||
|
- 100% async
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
**Creating a Project**
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install ohmyapi # TODO: not yet published
|
||||||
|
ohmyapi startproject myproject
|
||||||
|
cd myproject
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create the following directory structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
myproject/
|
||||||
|
- pyproject.toml
|
||||||
|
- settings.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Run your project with:
|
||||||
|
|
||||||
|
```
|
||||||
|
ohmyapi serve
|
||||||
|
```
|
||||||
|
|
||||||
|
In your browser go to:
|
||||||
|
- http://localhost:8000/docs
|
||||||
|
|
||||||
|
**Creating an App**
|
||||||
|
|
||||||
|
Create a new app by:
|
||||||
|
|
||||||
|
```
|
||||||
|
ohmyapi startapp myapp
|
||||||
|
```
|
||||||
|
|
||||||
|
This will lead to the following directory structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
myproject/
|
||||||
|
- myapp/
|
||||||
|
- __init__.py
|
||||||
|
- models.py
|
||||||
|
- routes.py
|
||||||
|
- pyproject.toml
|
||||||
|
- settings.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Add 'myapp' to your `INSTALLED_APPS` in `settings.py`.
|
||||||
|
|
||||||
|
Write your first model in `myapp/models.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ohmyapi.db import Model, field
|
||||||
|
|
||||||
|
|
||||||
|
class Person(Model):
|
||||||
|
id: int = field.IntField(min=1, pk=True)
|
||||||
|
name: str = field.CharField(min_length=1, max_length=255)
|
||||||
|
username: str = field.CharField(min_length=1, max_length=255, unique=True)
|
||||||
|
age: int = field.IntField(min=0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, create your endpoints in `myapp/routes.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from tortoise.exceptions import DoesNotExist
|
||||||
|
|
||||||
|
from .models import Person
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/myapp")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list():
|
||||||
|
return await Person.all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/:id")
|
||||||
|
async def get(id: int):
|
||||||
|
try:
|
||||||
|
await Person.get(pk=id)
|
||||||
|
except DoesNotExist:
|
||||||
|
raise HTTPException(status_code=404, detail="item not found")
|
||||||
|
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
Before we can run the app, we need to create and initialize the database.
|
||||||
|
|
||||||
|
Similar to Django, first run:
|
||||||
|
|
||||||
|
```
|
||||||
|
ohmyapi makemigrations [ <app> ] # no app means all INSTALLED_APPS
|
||||||
|
```
|
||||||
|
|
||||||
|
And the apply your migrations via:
|
||||||
|
|
||||||
|
```
|
||||||
|
ohmyapi migrate [ <app> ] # no app means all INSTALLED_APPS
|
||||||
|
```
|
||||||
|
|
||||||
|
Run your project:
|
||||||
|
|
||||||
|
```
|
||||||
|
ohmyapi serve
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shell
|
||||||
|
|
||||||
|
Similar to Django, you can attach to an interactive shell with your project already loaded inside.
|
||||||
|
|
||||||
|
```
|
||||||
|
ohmyapi shell
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
A builtin auth app is available.
|
||||||
|
Simply add `ohmyapi_auth` to your INSTALLED_APPS and define a JWT_SECRET in your `settings.py`.
|
||||||
|
Remember to `makemigrations` and `migrate` for the auth tables to be created in the database.
|
||||||
|
|
||||||
|
`settings.py`:
|
||||||
|
|
||||||
|
```
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'ohmyapi_auth',
|
||||||
|
...
|
||||||
|
]
|
||||||
|
|
||||||
|
JWT_SECRET = "t0ps3cr3t"
|
||||||
|
```
|
||||||
1378
poetry.lock
generated
Normal file
1378
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
34
pyproject.toml
Normal file
34
pyproject.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
[project]
|
||||||
|
name = "ohmyapi"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Django-like but async web-framework based on FastAPI and TortoiseORM."
|
||||||
|
authors = [
|
||||||
|
{name = "Brian Wiborg", email = "me@brianwib.org"}
|
||||||
|
]
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"typer (>=0.19.1,<0.20.0)",
|
||||||
|
"jinja2 (>=3.1.6,<4.0.0)",
|
||||||
|
"fastapi (>=0.117.1,<0.118.0)",
|
||||||
|
"tortoise-orm (>=0.25.1,<0.26.0)",
|
||||||
|
"aerich (>=0.9.1,<0.10.0)",
|
||||||
|
"uvicorn (>=0.36.0,<0.37.0)",
|
||||||
|
"ipython (>=9.5.0,<10.0.0)",
|
||||||
|
"passlib (>=1.7.4,<2.0.0)",
|
||||||
|
"pyjwt (>=2.10.1,<3.0.0)",
|
||||||
|
"python-multipart (>=0.0.20,<0.0.21)",
|
||||||
|
"crypto (>=1.4.1,<2.0.0)",
|
||||||
|
"argon2-cffi (>=25.1.0,<26.0.0)",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.poetry]
|
||||||
|
packages = [{include = "ohmyapi", from = "src"}]
|
||||||
|
|
||||||
|
[tool.poetry.scripts]
|
||||||
|
ohmyapi = "ohmyapi.cli:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
2
src/ohmyapi/__init__.py
Normal file
2
src/ohmyapi/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import db
|
||||||
|
|
||||||
4
src/ohmyapi/builtin/auth/__init__.py
Normal file
4
src/ohmyapi/builtin/auth/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from . import models
|
||||||
|
from . import routes
|
||||||
|
from . import permissions
|
||||||
|
|
||||||
36
src/ohmyapi/builtin/auth/models.py
Normal file
36
src/ohmyapi/builtin/auth/models.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
from typing import Optional, List
|
||||||
|
from ohmyapi.db import Model, field
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
class Group(Model):
|
||||||
|
id = field.IntField(pk=True)
|
||||||
|
name = field.CharField(max_length=42)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Model):
|
||||||
|
id = field.IntField(pk=True)
|
||||||
|
username = field.CharField(max_length=150, unique=True)
|
||||||
|
password_hash = field.CharField(max_length=128)
|
||||||
|
is_admin = field.BooleanField(default=False)
|
||||||
|
is_staff = field.BooleanField(default=False)
|
||||||
|
groups: Optional[List[Group]] = field.ManyToManyField("ohmyapi_auth.Group", related_name="users")
|
||||||
|
|
||||||
|
def set_password(self, raw_password: str) -> None:
|
||||||
|
"""Hash and store the password."""
|
||||||
|
self.password_hash = pwd_context.hash(raw_password)
|
||||||
|
|
||||||
|
def verify_password(self, raw_password: str) -> bool:
|
||||||
|
"""Verify a plaintext password against the stored hash."""
|
||||||
|
return pwd_context.verify(raw_password, self.password_hash)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def authenticate(cls, username: str, password: str) -> Optional["User"]:
|
||||||
|
"""Authenticate a user by username and password."""
|
||||||
|
user = await cls.filter(username=username).first()
|
||||||
|
if user and user.verify_password(password):
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
6
src/ohmyapi/builtin/auth/permissions.py
Normal file
6
src/ohmyapi/builtin/auth/permissions.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from .routes import (
|
||||||
|
get_current_user,
|
||||||
|
require_authenticated,
|
||||||
|
require_admin,
|
||||||
|
require_staff,
|
||||||
|
)
|
||||||
136
src/ohmyapi/builtin/auth/routes.py
Normal file
136
src/ohmyapi/builtin/auth/routes.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import time
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import APIRouter, Body, Depends, Header, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ohmyapi.builtin.auth.models import User
|
||||||
|
|
||||||
|
import settings
|
||||||
|
|
||||||
|
# Router
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
# Secrets & config (should come from settings/env in real projects)
|
||||||
|
JWT_SECRET = getattr(settings, "JWT_SECRET", "changeme")
|
||||||
|
JWT_ALGORITHM = getattr(settings, "JWT_ALGORITHM", "HS256")
|
||||||
|
ACCESS_TOKEN_EXPIRE_SECONDS = getattr(settings, "JWT_ACCESS_TOKEN_EXPIRE_SECONDS", 15 * 60)
|
||||||
|
REFRESH_TOKEN_EXPIRE_SECONDS = getattr(settings, "JWT_REFRESH_TOKEN_EXPIRE_SECONDS", 7 * 24 * 60 * 60)
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||||
|
|
||||||
|
|
||||||
|
def create_token(data: dict, expires_in: int) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
to_encode.update({"exp": int(time.time()) + expires_in})
|
||||||
|
token = jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||||
|
if isinstance(token, bytes):
|
||||||
|
token = token.decode("utf-8")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> Dict:
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
|
||||||
|
"""Dependency: extract user from access token."""
|
||||||
|
payload = decode_token(token)
|
||||||
|
username = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
|
||||||
|
|
||||||
|
user = await User.filter(username=username).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_authenticated(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Ensure the current user is an admin."""
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(403, "Authentication required")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Ensure the current user is an admin."""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(403, "Admin privileges required")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_staff(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
"""Ensure the current user is a staff member."""
|
||||||
|
if not current_user.is_staff:
|
||||||
|
raise HTTPException(403, "Staff privileges required")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_group(
|
||||||
|
group_name: str,
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
) -> User:
|
||||||
|
"""Ensure the current user belongs to the given group."""
|
||||||
|
user_groups = await current_user.groups.all()
|
||||||
|
if not any(g.name == group_name for g in user_groups):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"User must belong to group '{group_name}'"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(form_data: LoginRequest = Body(...)):
|
||||||
|
"""Login with username & password, returns access and refresh tokens."""
|
||||||
|
user = await User.authenticate(form_data.username, form_data.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
|
||||||
|
access_token = create_token({"sub": user.username, "type": "access"}, ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||||
|
refresh_token = create_token({"sub": user.username, "type": "refresh"}, REFRESH_TOKEN_EXPIRE_SECONDS)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"token_type": "bearer"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh")
|
||||||
|
async def refresh_token(refresh_token: str):
|
||||||
|
"""Exchange refresh token for new access token."""
|
||||||
|
payload = decode_token(refresh_token)
|
||||||
|
if payload.get("type") != "refresh":
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
|
||||||
|
|
||||||
|
username = payload.get("sub")
|
||||||
|
user = await User.filter(username=username).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
|
|
||||||
|
new_access = create_token({"sub": user.username, "type": "access"}, ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||||
|
return {"access_token": new_access, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(current_user: User = Depends(get_current_user)):
|
||||||
|
"""Return the currently authenticated user."""
|
||||||
|
return {
|
||||||
|
"username": current_user.username,
|
||||||
|
"is_admin": current_user.is_admin,
|
||||||
|
"is_staff": current_user.is_staff,
|
||||||
|
}
|
||||||
114
src/ohmyapi/cli.py
Normal file
114
src/ohmyapi/cli.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import asyncio
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
import typer
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from getpass import getpass
|
||||||
|
from ohmyapi.core import scaffolding, runtime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
app = typer.Typer(help="OhMyAPI — Django-flavored FastAPI scaffolding with tightly integrated TortoiseORM.")
|
||||||
|
banner = """OhMyAPI Shell | Project: {project_name}"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def startproject(name: str):
|
||||||
|
"""Create a new OhMyAPI project in the given directory"""
|
||||||
|
scaffolding.startproject(name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def startapp(app_name: str, root: str = "."):
|
||||||
|
"""Create a new app with the given name in your OhMyAPI project"""
|
||||||
|
scaffolding.startapp(app_name, root)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def serve(root: str = ".", host="127.0.0.1", port=8000):
|
||||||
|
"""
|
||||||
|
Run this project in via uvicorn.
|
||||||
|
"""
|
||||||
|
project_path = Path(root)
|
||||||
|
project = runtime.Project(project_path)
|
||||||
|
app_instance = project.app()
|
||||||
|
uvicorn.run(app_instance, host=host, port=int(port), reload=False)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def shell(root: str = "."):
|
||||||
|
"""
|
||||||
|
Launch an interactive IPython shell with the project and apps loaded.
|
||||||
|
"""
|
||||||
|
project_path = Path(root).resolve()
|
||||||
|
project = runtime.Project(project_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from IPython import start_ipython
|
||||||
|
shell_vars = {
|
||||||
|
"settings": project.settings,
|
||||||
|
"project": Path(project_path).resolve(),
|
||||||
|
}
|
||||||
|
from traitlets.config.loader import Config
|
||||||
|
c = Config()
|
||||||
|
c.TerminalIPythonApp.display_banner = True
|
||||||
|
c.TerminalInteractiveShell.banner1 = banner.format(**{
|
||||||
|
"project_name": f"{f'{project.settings.PROJECT_NAME} ' if getattr(project.settings, 'PROJECT_NAME', '') else ''}[{Path(project_path).resolve()}]",
|
||||||
|
})
|
||||||
|
c.TerminalInteractiveShell.banner2 = " "
|
||||||
|
start_ipython(argv=[], user_ns=shell_vars, config=c)
|
||||||
|
except ImportError:
|
||||||
|
typer.echo("IPython is not installed. Falling back to built-in Python shell.")
|
||||||
|
import code
|
||||||
|
code.interact(local={"settings": project.settings})
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def makemigrations(app: str = "*", name: str = "auto", root: str = "."):
|
||||||
|
"""
|
||||||
|
Create a DB migration based on your models.
|
||||||
|
"""
|
||||||
|
project_path = Path(root).resolve()
|
||||||
|
project = runtime.Project(project_path)
|
||||||
|
if app == "*":
|
||||||
|
for app in project.apps.keys():
|
||||||
|
asyncio.run(project.makemigrations(app_label=app, name=name))
|
||||||
|
else:
|
||||||
|
asyncio.run(project.makemigrations(app_label=app, name=name))
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def migrate(app: str = "*", root: str = "."):
|
||||||
|
"""
|
||||||
|
Run all DB migrations.
|
||||||
|
"""
|
||||||
|
project_path = Path(root).resolve()
|
||||||
|
project = runtime.Project(project_path)
|
||||||
|
if app == "*":
|
||||||
|
for app in project.apps.keys():
|
||||||
|
asyncio.run(project.migrate(app))
|
||||||
|
else:
|
||||||
|
asyncio.run(project.migrate(app))
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def createsuperuser(root: str = "."):
|
||||||
|
project_path = Path(root).resolve()
|
||||||
|
project = runtime.Project(project_path)
|
||||||
|
if not project.is_app_installed("ohmyapi_auth"):
|
||||||
|
print("Auth app not installed! Please add 'ohmyapi_auth' to your INSTALLED_APPS.")
|
||||||
|
return
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ohmyapi_auth
|
||||||
|
username = input("Username: ")
|
||||||
|
password = getpass("Password: ")
|
||||||
|
user = ohmyapi_auth.models.User(username=username, is_staff=True, is_admin=True)
|
||||||
|
user.set_password(password)
|
||||||
|
asyncio.run(project.init_orm())
|
||||||
|
asyncio.run(user.save())
|
||||||
|
asyncio.run(project.close_orm())
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app()
|
||||||
|
|
||||||
0
src/ohmyapi/core/__init__.py
Normal file
0
src/ohmyapi/core/__init__.py
Normal file
246
src/ohmyapi/core/runtime.py
Normal file
246
src/ohmyapi/core/runtime.py
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
# ohmyapi/core/runtime.py
|
||||||
|
import copy
|
||||||
|
import importlib
|
||||||
|
import importlib.util
|
||||||
|
import pkgutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
from aerich import Command as AerichCommand
|
||||||
|
from aerich.exceptions import NotInitedError
|
||||||
|
from tortoise import Tortoise
|
||||||
|
from fastapi import FastAPI, APIRouter
|
||||||
|
from ohmyapi.db.model import Model
|
||||||
|
|
||||||
|
|
||||||
|
class App:
|
||||||
|
"""App container holding runtime data like detected models and routes."""
|
||||||
|
|
||||||
|
def __init__(self, project: "OhMyAPI Project", name: str):
|
||||||
|
self.project = project
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
# The list of module paths (e.g. "ohmyapi_auth.models") for Tortoise and Aerich
|
||||||
|
self.model_modules: List[str] = []
|
||||||
|
|
||||||
|
# The APIRouter
|
||||||
|
self.router: Optional[APIRouter] = None
|
||||||
|
|
||||||
|
# Import the app, so its __init__.py runs.
|
||||||
|
importlib.import_module(self.name)
|
||||||
|
|
||||||
|
# Load the models
|
||||||
|
try:
|
||||||
|
models_mod = importlib.import_module(f"{self.name}.models")
|
||||||
|
self.model_modules.append(f"{self.name}.models")
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Locate the APIRouter
|
||||||
|
try:
|
||||||
|
routes_mod = importlib.import_module(f"{self.name}.routes")
|
||||||
|
router = getattr(routes_mod, "router", None)
|
||||||
|
if isinstance(router, APIRouter):
|
||||||
|
self.router = router
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
out = ""
|
||||||
|
out += f"App: {self.name}\n"
|
||||||
|
out += f"Models:\n"
|
||||||
|
for model in self.models:
|
||||||
|
out += f" - {model.__name__}\n"
|
||||||
|
out += "Routes:\n"
|
||||||
|
for route in (self.routes or []):
|
||||||
|
out += f" - {route}\n"
|
||||||
|
return out
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.__repr__()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def models(self) -> List[Model]:
|
||||||
|
models: List[Model] = []
|
||||||
|
for mod in self.model_modules:
|
||||||
|
models_mod = importlib.import_module(mod)
|
||||||
|
for obj in models_mod.__dict__.values():
|
||||||
|
if isinstance(obj, type) and getattr(obj, "_meta", None) is not None and obj.__name__ != 'Model':
|
||||||
|
models.append(obj)
|
||||||
|
return models
|
||||||
|
|
||||||
|
@property
|
||||||
|
def routes(self):
|
||||||
|
return self.router.routes
|
||||||
|
|
||||||
|
|
||||||
|
class Project:
|
||||||
|
"""
|
||||||
|
Project runtime loader + Tortoise/Aerich integration.
|
||||||
|
|
||||||
|
- injects builtin apps as ohmyapi_<name>
|
||||||
|
- builds unified tortoise config for runtime
|
||||||
|
- provides makemigrations/migrate methods using Aerich Command API
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, project_path: str):
|
||||||
|
self.project_path = Path(project_path).resolve()
|
||||||
|
self._apps: Dict[str, App] = {}
|
||||||
|
self.migrations_dir = self.project_path / "migrations"
|
||||||
|
|
||||||
|
if str(self.project_path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(self.project_path))
|
||||||
|
|
||||||
|
# Pre-register builtin apps as ohmyapi_<name>.
|
||||||
|
# This makes all builtin apps easily loadable via f"ohmyapi_{app_name}".
|
||||||
|
spec = importlib.util.find_spec("ohmyapi.builtin")
|
||||||
|
if spec and spec.submodule_search_locations:
|
||||||
|
for _, modname, _ in pkgutil.iter_modules(spec.submodule_search_locations):
|
||||||
|
full = f"ohmyapi.builtin.{modname}"
|
||||||
|
alias = f"ohmyapi_{modname}"
|
||||||
|
if alias not in sys.modules:
|
||||||
|
orig = importlib.import_module(full)
|
||||||
|
sys.modules[alias] = orig
|
||||||
|
try:
|
||||||
|
sys.modules[f"{alias}.models"] = importlib.import_module(f"{full}.models")
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Load settings.py
|
||||||
|
try:
|
||||||
|
self.settings = importlib.import_module("settings")
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to import project settings from {self.project_path}") from e
|
||||||
|
|
||||||
|
# Load installed apps
|
||||||
|
for app_name in getattr(self.settings, "INSTALLED_APPS", []):
|
||||||
|
self._apps[app_name] = App(self, name=app_name)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def apps(self):
|
||||||
|
return self._apps
|
||||||
|
|
||||||
|
def is_app_installed(self, name: str) -> bool:
|
||||||
|
return name in getattr(self.settings, "INSTALLED_APPS", [])
|
||||||
|
|
||||||
|
def app(self, generate_schemas: bool = False) -> FastAPI:
|
||||||
|
"""
|
||||||
|
Create a FastAPI app, attach all APIRouters from registered apps,
|
||||||
|
and register ORM lifecycle event handlers.
|
||||||
|
"""
|
||||||
|
app = FastAPI(title=getattr(self.settings, "PROJECT_NAME", "OhMyAPI Project"))
|
||||||
|
|
||||||
|
# Attach routers from apps
|
||||||
|
for app_name, app_def in self._apps.items():
|
||||||
|
if app_def.router:
|
||||||
|
app.include_router(app_def.router)
|
||||||
|
|
||||||
|
# Startup / shutdown events
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def _startup():
|
||||||
|
await self.init_orm(generate_schemas=generate_schemas)
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def _shutdown():
|
||||||
|
await self.close_orm()
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
# --- Config builders ---
|
||||||
|
def build_tortoise_config(self, db_url: Optional[str] = None) -> dict:
|
||||||
|
"""
|
||||||
|
Build unified Tortoise config for all registered apps.
|
||||||
|
"""
|
||||||
|
db = db_url or getattr(self.settings, "DATABASE_URL", "sqlite://db.sqlite3")
|
||||||
|
config = {
|
||||||
|
"connections": {"default": db},
|
||||||
|
"apps": {},
|
||||||
|
"tortoise": "Tortoise",
|
||||||
|
"migrations_dir": str(self.migrations_dir),
|
||||||
|
}
|
||||||
|
|
||||||
|
for app_name, app in self._apps.items():
|
||||||
|
modules = list(dict.fromkeys(app.model_modules))
|
||||||
|
if modules:
|
||||||
|
config["apps"][app_name] = {"models": modules, "default_connection": "default"}
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def build_aerich_command(self, app_label: str, db_url: Optional[str] = None) -> AerichCommand:
|
||||||
|
# Resolve label to flat_label
|
||||||
|
if app_label in self._apps:
|
||||||
|
flat_label = app_label
|
||||||
|
else:
|
||||||
|
candidate = app_label.replace(".", "_")
|
||||||
|
if candidate in self._apps:
|
||||||
|
flat_label = candidate
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"App '{app_label}' is not registered")
|
||||||
|
|
||||||
|
# Get a fresh copy of the config (without aerich.models anywhere)
|
||||||
|
tortoise_cfg = copy.deepcopy(self.build_tortoise_config(db_url=db_url))
|
||||||
|
|
||||||
|
# Append aerich.models to the models list of the target app only
|
||||||
|
if flat_label in tortoise_cfg["apps"]:
|
||||||
|
tortoise_cfg["apps"][flat_label]["models"].append("aerich.models")
|
||||||
|
|
||||||
|
return AerichCommand(
|
||||||
|
tortoise_config=tortoise_cfg,
|
||||||
|
app=flat_label,
|
||||||
|
location=str(self.migrations_dir)
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- ORM lifecycle ---
|
||||||
|
async def init_orm(self, generate_schemas: bool = False) -> None:
|
||||||
|
if not Tortoise.apps:
|
||||||
|
cfg = self.build_tortoise_config()
|
||||||
|
await Tortoise.init(config=cfg)
|
||||||
|
if generate_schemas:
|
||||||
|
await Tortoise.generate_schemas(safe=True)
|
||||||
|
|
||||||
|
async def close_orm(self) -> None:
|
||||||
|
await Tortoise.close_connections()
|
||||||
|
|
||||||
|
# --- Migration helpers ---
|
||||||
|
async def makemigrations(self, app_label: str, name: str = "auto", db_url: Optional[str] = None) -> None:
|
||||||
|
cmd = self.build_aerich_command(app_label, db_url=db_url)
|
||||||
|
async with cmd as c:
|
||||||
|
await c.init()
|
||||||
|
try:
|
||||||
|
await c.init_db(safe=True)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await c.migrate(name=name)
|
||||||
|
except (NotInitedError, click.UsageError):
|
||||||
|
await c.init_db(safe=True)
|
||||||
|
await c.migrate(name=name)
|
||||||
|
|
||||||
|
async def migrate(self, app_label: Optional[str] = None, db_url: Optional[str] = None) -> None:
|
||||||
|
labels: List[str]
|
||||||
|
if app_label:
|
||||||
|
if app_label in self._apps:
|
||||||
|
labels = [app_label]
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Unknown app '{app_label}'")
|
||||||
|
else:
|
||||||
|
labels = list(self._apps.keys())
|
||||||
|
|
||||||
|
for lbl in labels:
|
||||||
|
cmd = self.build_aerich_command(lbl, db_url=db_url)
|
||||||
|
async with cmd as c:
|
||||||
|
await c.init()
|
||||||
|
try:
|
||||||
|
await c.init_db(safe=True)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to apply migrations
|
||||||
|
await c.upgrade()
|
||||||
|
except (NotInitedError, click.UsageError):
|
||||||
|
# No migrations yet, initialize then retry upgrade
|
||||||
|
await c.init_db(safe=True)
|
||||||
|
await c.upgrade()
|
||||||
61
src/ohmyapi/core/scaffolding.py
Normal file
61
src/ohmyapi/core/scaffolding.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
# Base templates directory
|
||||||
|
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
||||||
|
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
|
||||||
|
|
||||||
|
|
||||||
|
def render_template_file(template_path: Path, context: dict, output_path: Path):
|
||||||
|
"""Render a single Jinja2 template file to disk."""
|
||||||
|
template = env.get_template(str(template_path.relative_to(TEMPLATE_DIR)).replace("\\", "/"))
|
||||||
|
content = template.render(**context)
|
||||||
|
os.makedirs(output_path.parent, exist_ok=True)
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
def render_template_dir(template_subdir: str, target_dir: Path, context: dict, subdir_name: str | None = None):
|
||||||
|
"""
|
||||||
|
Recursively render all *.j2 templates from TEMPLATE_DIR/template_subdir into target_dir.
|
||||||
|
If subdir_name is given, files are placed inside target_dir/subdir_name.
|
||||||
|
"""
|
||||||
|
template_dir = TEMPLATE_DIR / template_subdir
|
||||||
|
for root, _, files in os.walk(template_dir):
|
||||||
|
root_path = Path(root)
|
||||||
|
rel_root = root_path.relative_to(template_dir) # path relative to template_subdir
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
if not f.endswith(".j2"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
template_rel_path = rel_root / f
|
||||||
|
output_rel_path = Path(*template_rel_path.parts).with_suffix("") # remove .j2
|
||||||
|
|
||||||
|
# optionally wrap in subdir_name
|
||||||
|
if subdir_name:
|
||||||
|
output_path = target_dir / subdir_name / output_rel_path
|
||||||
|
else:
|
||||||
|
output_path = target_dir / output_rel_path
|
||||||
|
|
||||||
|
render_template_file(template_dir / template_rel_path, context, output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def startproject(name: str):
|
||||||
|
"""Create a new project: flat structure, all project templates go into <name>/"""
|
||||||
|
target_dir = Path(name).resolve()
|
||||||
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
|
render_template_dir("project", target_dir, {"project_name": name})
|
||||||
|
print(f"✅ Project '{name}' created successfully.")
|
||||||
|
print(f"🔧 Next, configure your project in {target_dir / 'settings.py'}")
|
||||||
|
|
||||||
|
|
||||||
|
def startapp(name: str, project: str):
|
||||||
|
"""Create a new app inside a project: templates go into <project_dir>/<name>/"""
|
||||||
|
target_dir = Path(project)
|
||||||
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
|
render_template_dir("app", target_dir, {"project_name": project, "app_name": name}, subdir_name=name)
|
||||||
|
print(f"✅ App '{name}' created in project '{target_dir}' successfully.")
|
||||||
|
print(f"🔧 Remember to add '{name}' to your INSTALLED_APPS!")
|
||||||
|
|
||||||
3
src/ohmyapi/core/templates/app/__init__.py.j2
Normal file
3
src/ohmyapi/core/templates/app/__init__.py.j2
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from . import models
|
||||||
|
from . import routes
|
||||||
|
|
||||||
6
src/ohmyapi/core/templates/app/models.py.j2
Normal file
6
src/ohmyapi/core/templates/app/models.py.j2
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from ohmyapi.db import Model, field
|
||||||
|
|
||||||
|
|
||||||
|
class MyModel(Model):
|
||||||
|
id: int = field.IntField(min=1, pk=True)
|
||||||
|
...
|
||||||
13
src/ohmyapi/core/templates/app/routes.py.j2
Normal file
13
src/ohmyapi/core/templates/app/routes.py.j2
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from ohmyapi.router import APIRouter
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/{{ app_name }}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def ping():
|
||||||
|
return {
|
||||||
|
"project": "{{ project_name }}",
|
||||||
|
"app": "{{ app_name }}",
|
||||||
|
}
|
||||||
|
|
||||||
13
src/ohmyapi/core/templates/project/pyproject.toml.j2
Normal file
13
src/ohmyapi/core/templates/project/pyproject.toml.j2
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[tool.poetry]
|
||||||
|
name = "{{ project_name }}"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "OhMyAPI project"
|
||||||
|
authors = ["You <you@example.com>"]
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.10"
|
||||||
|
fastapi = "^0.115"
|
||||||
|
uvicorn = "^0.30"
|
||||||
|
tortoise-orm = "^0.20"
|
||||||
|
aerich = "^0.7"
|
||||||
|
|
||||||
5
src/ohmyapi/core/templates/project/settings.py.j2
Normal file
5
src/ohmyapi/core/templates/project/settings.py.j2
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# {{ project_name }} settings.py
|
||||||
|
PROJECT_NAME = "MyProject"
|
||||||
|
DATABASE_URL = "sqlite://db.sqlite3"
|
||||||
|
INSTALLED_APPS = []
|
||||||
|
|
||||||
3
src/ohmyapi/db/__init__.py
Normal file
3
src/ohmyapi/db/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from tortoise import fields as field
|
||||||
|
from .model import Model
|
||||||
|
|
||||||
90
src/ohmyapi/db/migration_manager.py
Normal file
90
src/ohmyapi/db/migration_manager.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from aerich import Command
|
||||||
|
from ohmyapi.core import runtime
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationManager:
|
||||||
|
def __init__(self, project):
|
||||||
|
self.project = project
|
||||||
|
self._commands = {}
|
||||||
|
# Compute tortoise_config grouped by app module
|
||||||
|
self._tortoise_config = self._build_tortoise_config()
|
||||||
|
|
||||||
|
def _build_tortoise_config(self) -> dict:
|
||||||
|
"""
|
||||||
|
Build Tortoise config from the flat model_registry,
|
||||||
|
grouping models by app module for Aerich compatibility.
|
||||||
|
"""
|
||||||
|
db_url = self.project.settings.DATABASE_URL
|
||||||
|
registry = self.project.model_registry # flat: model_path -> class
|
||||||
|
|
||||||
|
apps_modules = {}
|
||||||
|
for model_path, model_cls in registry.items():
|
||||||
|
if not isinstance(model_cls, type):
|
||||||
|
raise TypeError(f"Registry value must be a class, got {type(model_cls)}: {model_cls}")
|
||||||
|
# Extract app module by removing the model class name
|
||||||
|
# Example: 'ohmyapi.apps.auth.User' -> 'ohmyapi.apps.auth'
|
||||||
|
app_module = ".".join(model_path.split(".")[:-1])
|
||||||
|
apps_modules.setdefault(app_module, []).append(model_cls)
|
||||||
|
|
||||||
|
# Build Tortoise config
|
||||||
|
apps_config = {}
|
||||||
|
for app_module, models in apps_modules.items():
|
||||||
|
modules_set = set(m.__module__ for m in models)
|
||||||
|
apps_config[app_module] = {
|
||||||
|
"models": list(modules_set),
|
||||||
|
"default_connection": "default",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"connections": {"default": db_url},
|
||||||
|
"apps": apps_config,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_apps(self):
|
||||||
|
"""Return app modules extracted from the registry"""
|
||||||
|
return list(self._tortoise_config["apps"].keys())
|
||||||
|
|
||||||
|
def get_migration_location(self, app_module: str) -> str:
|
||||||
|
"""Return the path to the app's migrations folder"""
|
||||||
|
try:
|
||||||
|
module = __import__(app_module, fromlist=["migrations"])
|
||||||
|
if not hasattr(module, "__file__") or module.__file__ is None:
|
||||||
|
raise ValueError(f"Cannot determine filesystem path for app '{app_module}'")
|
||||||
|
app_path = Path(module.__file__).parent
|
||||||
|
migrations_path = app_path / "migrations"
|
||||||
|
migrations_path.mkdir(exist_ok=True)
|
||||||
|
return str(migrations_path)
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
raise ValueError(f"App module '{app_module}' cannot be imported")
|
||||||
|
|
||||||
|
async def init_app_command(self, app_module: str) -> Command:
|
||||||
|
"""Initialize Aerich command for a specific app module"""
|
||||||
|
location = self.get_migration_location(app_module)
|
||||||
|
cmd = Command(
|
||||||
|
tortoise_config=self._tortoise_config,
|
||||||
|
app=app_module,
|
||||||
|
location=location,
|
||||||
|
)
|
||||||
|
await cmd.init()
|
||||||
|
self._commands[app_module] = cmd
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
async def makemigrations(self, app_module: str):
|
||||||
|
"""Generate migrations for a specific app"""
|
||||||
|
cmd = self._commands.get(app_module) or await self.init_app_command(app_module)
|
||||||
|
await cmd.migrate()
|
||||||
|
|
||||||
|
async def migrate(self, app_module: str = None):
|
||||||
|
"""Apply migrations. If app_module is None, migrate all apps"""
|
||||||
|
apps_to_migrate = [app_module] if app_module else self.get_apps()
|
||||||
|
for app in apps_to_migrate:
|
||||||
|
cmd = self._commands.get(app) or await self.init_app_command(app)
|
||||||
|
await cmd.upgrade()
|
||||||
|
|
||||||
|
async def show_migrations(self, app_module: str):
|
||||||
|
"""List migrations for an app"""
|
||||||
|
cmd = self._commands.get(app_module) or await self.init_app_command(app_module)
|
||||||
|
await cmd.history()
|
||||||
|
|
||||||
1
src/ohmyapi/db/model/__init__.py
Normal file
1
src/ohmyapi/db/model/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from .model import Model, fields
|
||||||
80
src/ohmyapi/db/model/model.py
Normal file
80
src/ohmyapi/db/model/model.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
from tortoise import fields
|
||||||
|
from tortoise.models import Model as TortoiseModel
|
||||||
|
from tortoise.contrib.pydantic import pydantic_model_creator, pydantic_queryset_creator
|
||||||
|
|
||||||
|
|
||||||
|
class Model(TortoiseModel):
|
||||||
|
"""
|
||||||
|
Base Tortoise model with attached Pydantic schema generators via .Schema
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Schema:
|
||||||
|
"""
|
||||||
|
Provides convenient access to auto-generated Pydantic schemas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model_cls):
|
||||||
|
self.model_cls = model_cls
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self):
|
||||||
|
# Minimal schema with just the primary key field
|
||||||
|
pk_field = self.model_cls._meta.pk_attr
|
||||||
|
return pydantic_model_creator(
|
||||||
|
self.model_cls, name=f"{self.model_cls.__name__}SchemaId", include=(pk_field,)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def get(self):
|
||||||
|
# Full schema for reading
|
||||||
|
return pydantic_model_creator(
|
||||||
|
self.model_cls, name=f"{self.model_cls.__name__}SchemaGet"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def post(self):
|
||||||
|
# Input schema for creation (no readonly fields like ID/PK)
|
||||||
|
return pydantic_model_creator(
|
||||||
|
self.model_cls,
|
||||||
|
name=f"{self.model_cls.__name__}SchemaPost",
|
||||||
|
exclude_readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def put(self):
|
||||||
|
# Input schema for updating
|
||||||
|
return pydantic_model_creator(
|
||||||
|
self.model_cls,
|
||||||
|
name=f"{self.model_cls.__name__}SchemaPut",
|
||||||
|
exclude_readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delete(self):
|
||||||
|
# Schema for delete operations (just PK)
|
||||||
|
pk_field = self.model_cls._meta.pk_attr
|
||||||
|
return pydantic_model_creator(
|
||||||
|
self.model_cls, name=f"{self.model_cls.__name__}SchemaDelete", include=(pk_field,)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def list(self):
|
||||||
|
# Schema for list endpoints
|
||||||
|
return pydantic_queryset_creator(self.model_cls)
|
||||||
|
|
||||||
|
def from_fields(self, *fields: str):
|
||||||
|
# Generate schema restricted to given fields
|
||||||
|
valid = [f for f in fields if f in self.model_cls._meta.fields_map]
|
||||||
|
return pydantic_model_creator(
|
||||||
|
self.model_cls,
|
||||||
|
name=f"{self.model_cls.__name__}SchemaFields",
|
||||||
|
include=valid,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init_subclass__(cls, **kwargs):
|
||||||
|
"""
|
||||||
|
Automatically attach .Schema to all subclasses
|
||||||
|
"""
|
||||||
|
super().__init_subclass__(**kwargs)
|
||||||
|
cls.Schema = cls.Schema(cls)
|
||||||
|
|
||||||
2
src/ohmyapi/router.py
Normal file
2
src/ohmyapi/router.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Loading…
Add table
Add a link
Reference in a new issue