Compare commits

..

No commits in common. "main" and "0.4.2" have entirely different histories.
main ... 0.4.2

15 changed files with 87 additions and 244 deletions

View file

@ -1,7 +1,7 @@
# Apps # Apps
Apps are a way to group database models and API routes that contextually belong together. Apps are a way to group database models and API routes that contextually belong together.
For example, OhMyAPI comes bundled with an `auth` app that carries a `User` model and provides API endpoints for JWT authentication. For example, OhMyAPI comes bundled with an `auth` app that carries a `User` and `Group` model and provides API endpoints for JWT authentication.
Apps help organizing projects by isolating individual components (or "features") from one another. Apps help organizing projects by isolating individual components (or "features") from one another.

View file

@ -40,6 +40,6 @@ router = APIRouter()
@router.get("/") @router.get("/")
def get(user: auth.User = Depends(permissions.require_authenticated)): def get(user: auth.User = Depends(permissions.required_authenticated)):
... ...
``` ```

113
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. # This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand.
[[package]] [[package]]
name = "aerich" name = "aerich"
@ -62,7 +62,7 @@ version = "4.11.0"
description = "High-level concurrency and networking framework on top of asyncio or Trio" description = "High-level concurrency and networking framework on top of asyncio or Trio"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main", "dev"] groups = ["main"]
files = [ files = [
{file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"},
{file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"},
@ -216,7 +216,7 @@ version = "2025.8.3"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main", "dev"] groups = ["main"]
files = [ files = [
{file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
{file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
@ -543,66 +543,19 @@ version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "dev"] groups = ["main"]
files = [ files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
] ]
[[package]]
name = "httpcore"
version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.16"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<1.0)"]
[[package]]
name = "httpx"
version = "0.28.1"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
httpcore = "==1.*"
idna = "*"
[package.extras]
brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.10" version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
groups = ["main", "dev"] groups = ["main"]
files = [ files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
@ -611,18 +564,6 @@ files = [
[package.extras] [package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]] [[package]]
name = "ipython" name = "ipython"
version = "9.6.0" version = "9.6.0"
@ -1083,22 +1024,6 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-a
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"]
type = ["mypy (>=1.14.1)"] type = ["mypy (>=1.14.1)"]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]] [[package]]
name = "prompt-toolkit" name = "prompt-toolkit"
version = "3.0.52" version = "3.0.52"
@ -1335,28 +1260,6 @@ files = [
{file = "pypika_tortoise-0.6.2.tar.gz", hash = "sha256:f95ab59d9b6454db2e8daa0934728458350a1f3d56e81d9d1debc8eebeff26b3"}, {file = "pypika_tortoise-0.6.2.tar.gz", hash = "sha256:f95ab59d9b6454db2e8daa0934728458350a1f3d56e81d9d1debc8eebeff26b3"},
] ]
[[package]]
name = "pytest"
version = "8.4.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
{file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1"
packaging = ">=20"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@ -1585,7 +1488,7 @@ version = "1.3.1"
description = "Sniff out which async library your code is running under" description = "Sniff out which async library your code is running under"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main", "dev"] groups = ["main"]
files = [ files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
@ -1701,7 +1604,7 @@ files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
] ]
markers = {dev = "python_version < \"3.13\""} markers = {dev = "python_version == \"3.11\""}
[[package]] [[package]]
name = "typing-inspection" name = "typing-inspection"
@ -1816,4 +1719,4 @@ auth = ["argon2-cffi", "crypto", "passlib", "pyjwt", "python-multipart"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.11" python-versions = ">=3.11"
content-hash = "cc1604995d3b73ee302e63731dd300ea17c4d95d0cfc6c386626dd9a9f60e8a7" content-hash = "3d301460081dada359d425d69feefc63c1e5135aa64b6f000f554bfc1231febd"

View file

@ -1,6 +1,6 @@
[project] [project]
name = "ohmyapi" name = "ohmyapi"
version = "0.6.2" version = "0.4.2"
description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations" description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations"
license = "MIT" license = "MIT"
keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"] keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"]
@ -30,8 +30,6 @@ ipython = ">=9.5.0,<10.0.0"
black = "^25.9.0" black = "^25.9.0"
isort = "^6.0.1" isort = "^6.0.1"
mkdocs = "^1.6.1" mkdocs = "^1.6.1"
pytest = "^8.4.2"
httpx = "^0.28.1"
[project.optional-dependencies] [project.optional-dependencies]
auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"] auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"]

View file

@ -1 +1 @@
__VERSION__ = "0.6.2" __VERSION__ = "0.4.2"

View file

@ -1,39 +1,53 @@
import hmac
import hashlib
import base64
from functools import wraps
from secrets import token_bytes
from typing import List, Optional
from uuid import UUID
from passlib.context import CryptContext
from tortoise.contrib.pydantic import pydantic_queryset_creator
from ohmyapi.db import Model, field, Q from ohmyapi.db import Model, field, Q
from ohmyapi.router import HTTPException from ohmyapi.router import HTTPException
from .utils import hmac_hash import settings
from datetime import datetime
from passlib.context import CryptContext
from typing import Optional
from uuid import UUID
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
SECRET_KEY = getattr(settings, "SECRET_KEY", "OhMyAPI Secret Key")
def hmac_hash(data: str) -> str:
digest = hmac.new(SECRET_KEY.encode("UTF-8"), data.encode("utf-8"), hashlib.sha256).digest()
return base64.urlsafe_b64encode(digest).decode("utf-8")
class Group(Model):
id: UUID = field.data.UUIDField(pk=True)
name: str = field.CharField(max_length=42, index=True)
def __str__(self):
return self.name if self.name else ""
class User(Model): class User(Model):
id: UUID = field.data.UUIDField(pk=True) id: UUID = field.data.UUIDField(pk=True)
username: str = field.CharField(max_length=150, unique=True) username: str = field.CharField(max_length=150, unique=True)
email_hash: str = field.CharField(max_length=255, unique=True, index=True) email_hash: Optional[str] = field.CharField(max_length=255, unique=True, index=True)
password_hash: str = field.CharField(max_length=128) password_hash: str = field.CharField(max_length=128)
is_admin: bool = field.BooleanField(default=False) is_admin: bool = field.BooleanField(default=False)
is_staff: bool = field.BooleanField(default=False) is_staff: bool = field.BooleanField(default=False)
created_at: datetime = field.DatetimeField(auto_now_add=True) groups: field.ManyToManyRelation[Group] = field.ManyToManyField(
updated_at: datetime = field.DatetimeField(auto_now=True) "ohmyapi_auth.Group", related_name="users", through="usergroups"
)
class Schema: class Schema:
include = { exclude = ["password_hash", "email_hash"]
"id",
"username",
"is_admin",
"is_staff"
"created_at",
"updated_at",
}
def __str__(self): def __str__(self):
fields = { fields = {
'username': self.username, 'username': self.username if self.username else "-",
'is_admin': 'y' if self.is_admin else 'n', 'is_admin': 'y' if self.is_admin else 'n',
'is_staff': 'y' if self.is_staff else 'n', 'is_staff': 'y' if self.is_staff else 'n',
} }
@ -52,7 +66,7 @@ class User(Model):
return pwd_context.verify(raw_password, self.password_hash) return pwd_context.verify(raw_password, self.password_hash)
@classmethod @classmethod
async def authenticate(cls, username: str, password: str) -> Optional["User"]: async def authenticate_username(cls, username: str, password: str) -> Optional["User"]:
"""Authenticate a user by username and password.""" """Authenticate a user by username and password."""
user = await cls.filter(username=username).first() user = await cls.filter(username=username).first()
if user and user.verify_password(password): if user and user.verify_password(password):

View file

@ -1,7 +1,6 @@
from .routes import ( from .routes import (
get_current_user, get_current_user,
get_token, get_token,
maybe_authenticated,
require_admin, require_admin,
require_authenticated, require_authenticated,
require_group, require_group,

View file

@ -1,17 +1,14 @@
import time import time
from enum import Enum from enum import Enum
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request, status
from fastapi.security import OAuth2, OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.security.utils import get_authorization_scheme_param
from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist
from ohmyapi.builtin.auth.models import User
import jwt import jwt
import settings import settings
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 Group, User
# Router # Router
router = APIRouter(prefix="/auth", tags=["Auth"]) router = APIRouter(prefix="/auth", tags=["Auth"])
@ -26,25 +23,12 @@ REFRESH_TOKEN_EXPIRE_SECONDS = getattr(
settings, "JWT_REFRESH_TOKEN_EXPIRE_SECONDS", 7 * 24 * 60 * 60 settings, "JWT_REFRESH_TOKEN_EXPIRE_SECONDS", 7 * 24 * 60 * 60
) )
class OptionalOAuth2PasswordBearer(OAuth2):
def __init__(self, tokenUrl: str):
super().__init__(flows={"password": {"tokenUrl": tokenUrl}}, scheme_name="OAuth2PasswordBearer")
async def __call__(self, request: Request) -> Optional[str]:
authorization: str = request.headers.get("Authorization")
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "bearer":
# No token provided — just return None
return None
return param
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
oauth2_optional_scheme = OptionalOAuth2PasswordBearer(tokenUrl="/auth/login")
class ClaimsUser(BaseModel): class ClaimsUser(BaseModel):
username: str username: str
email: str
is_admin: bool is_admin: bool
is_staff: bool is_staff: bool
@ -53,6 +37,7 @@ class Claims(BaseModel):
type: str type: str
sub: str sub: str
user: ClaimsUser user: ClaimsUser
roles: List[str]
exp: str exp: str
@ -79,15 +64,17 @@ class TokenType(str, Enum):
refresh = "refresh" refresh = "refresh"
def claims(token_type: TokenType, user: User = []) -> Claims: def claims(token_type: TokenType, user: User, groups: List[Group] = []) -> Claims:
return Claims( return Claims(
type=token_type, type=token_type,
sub=str(user.id), sub=str(user.id),
user=ClaimsUser( user=ClaimsUser(
username=user.username, username=user.username,
email=user.email,
is_admin=user.is_admin, is_admin=user.is_admin,
is_staff=user.is_staff, is_staff=user.is_staff,
), ),
roles=[g.name for g in groups],
exp="", exp="",
) )
@ -137,12 +124,6 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
return user return user
async def maybe_authenticated(token: Optional[str] = Depends(oauth2_optional_scheme)) -> Optional[User]:
if token is None:
return None
return await get_current_user(token)
async def require_authenticated(current_user: User = Depends(get_current_user)) -> User: async def require_authenticated(current_user: User = Depends(get_current_user)) -> User:
"""Ensure the current user is an admin.""" """Ensure the current user is an admin."""
if not current_user: if not current_user:
@ -199,25 +180,17 @@ async def login(form_data: LoginRequest = Body(...)):
) )
class TokenRefresh(BaseModel):
refresh_token: str
@router.post("/refresh", response_model=AccessToken) @router.post("/refresh", response_model=AccessToken)
async def refresh_token(refresh_token: TokenRefresh = Body(...)): async def refresh_token(refresh_token: str):
"""Exchange refresh token for new access token.""" """Exchange refresh token for new access token."""
payload = decode_token(refresh_token.refresh_token) payload = decode_token(refresh_token)
if payload.get("type") != "refresh": if payload.get("type") != "refresh":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token" status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
) )
user_id = payload.get("sub") user_id = payload.get("sub")
try: user = await User.filter(id=user_id).first()
user = await User.get(id=user_id)
except DoesNotExist:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
@ -226,7 +199,7 @@ async def refresh_token(refresh_token: TokenRefresh = Body(...)):
new_access = create_token( new_access = create_token(
claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS
) )
return AccessToken(token_type="bearer", access_token=new_access) return AccessToken(token_type="bearer", access_token=access_token)
@router.get("/introspect", response_model=Dict[str, Any]) @router.get("/introspect", response_model=Dict[str, Any])

View file

@ -1,17 +0,0 @@
import base64
import hashlib
import hmac
import settings
SECRET_KEY = getattr(settings, "SECRET_KEY", "OhMyAPI Secret Key")
def hmac_hash(data: str) -> str:
digest = hmac.new(
SECRET_KEY.encode("UTF-8"),
data.encode("utf-8"),
hashlib.sha256,
).digest()
return base64.urlsafe_b64encode(digest).decode("utf-8")

View file

@ -165,13 +165,6 @@ def createsuperuser(root: str = "."):
) )
user.set_email(email) user.set_email(email)
user.set_password(password1) user.set_password(password1)
asyncio.run(project.init_orm())
async def _run(): asyncio.run(user.save())
await project.init_orm() asyncio.run(project.close_orm())
user = ohmyapi_auth.models.User(username=username, is_staff=True, is_admin=True)
user.set_email(email)
user.set_password(password1)
await user.save()
await project.close_orm()
asyncio.run(_run())

View file

@ -40,14 +40,6 @@ class Project:
if str(self.project_path) not in sys.path: if str(self.project_path) not in sys.path:
sys.path.insert(0, str(self.project_path)) sys.path.insert(0, str(self.project_path))
# 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
# Alias builtin apps as ohmyapi_<name>. # Alias builtin apps as ohmyapi_<name>.
# We need this, because Tortoise app-names may not include dots `.`. # We need this, because Tortoise app-names may not include dots `.`.
spec = importlib.util.find_spec("ohmyapi.builtin") spec = importlib.util.find_spec("ohmyapi.builtin")
@ -65,6 +57,14 @@ class Project:
except ModuleNotFoundError: except ModuleNotFoundError:
pass 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 # Load installed apps
for app_name in getattr(self.settings, "INSTALLED_APPS", []): for app_name in getattr(self.settings, "INSTALLED_APPS", []):
self._apps[app_name] = App(self, name=app_name) self._apps[app_name] = App(self, name=app_name)
@ -129,7 +129,7 @@ class Project:
} }
for app_name, app in self._apps.items(): for app_name, app in self._apps.items():
modules = list(app._models.keys()) modules = list(app.models.keys())
if modules: if modules:
config["apps"][app_name] = { config["apps"][app_name] = {
"models": modules, "models": modules,
@ -154,10 +154,7 @@ class Project:
tortoise_cfg = self.build_tortoise_config(db_url=db_url) tortoise_cfg = self.build_tortoise_config(db_url=db_url)
# Prevent leaking other app's models to Aerich. # Prevent leaking other app's models to Aerich.
if app_label in tortoise_cfg["apps"].keys():
tortoise_cfg["apps"] = {app_label: tortoise_cfg["apps"][app_label]} tortoise_cfg["apps"] = {app_label: tortoise_cfg["apps"][app_label]}
else:
tortoise_cfg["apps"] = {app_label: {"default_connection": "default", "models": []}}
# Append aerich.models to the models list of the target app only # Append aerich.models to the models list of the target app only
tortoise_cfg["apps"][app_label]["models"].append("aerich.models") tortoise_cfg["apps"][app_label]["models"].append("aerich.models")
@ -342,9 +339,9 @@ class App:
except ModuleNotFoundError: except ModuleNotFoundError:
return return
installer = getattr(mod, "install", None) getter = getattr(mod, "get", None)
if installer is not None: if getter is not None:
for middleware in installer(): for middleware in getter():
self._middlewares.append(middleware) self._middlewares.append(middleware)
def __serialize_route(self, route): def __serialize_route(self, route):
@ -375,7 +372,9 @@ class App:
for module in self._models: for module in self._models:
for model in self._models[module]: for model in self._models[module]:
out.append(model) out.append(model)
return out return {
module: out,
}
@property @property
def routers(self): def routers(self):
@ -404,15 +403,10 @@ class App:
""" """
Convenience method for serializing the runtime data. Convenience method for serializing the runtime data.
""" """
# An app may come without any models
models = []
if f"{self.name}.models" in self._models:
models = [
f"{self.name}.{m.__name__}"
for m in self._models[f"{self.name}.models"]
]
return { return {
"models": models, "models": [
f"{self.name}.{m.__name__}" for m in self.models[f"{self.name}.models"]
],
"middlewares": self.__serialize_middleware(), "middlewares": self.__serialize_middleware(),
"routes": self.__serialize_router(), "routes": self.__serialize_router(),
} }

View file

@ -1,6 +1,5 @@
from tortoise.expressions import Q from tortoise.expressions import Q
from tortoise.manager import Manager from tortoise.manager import Manager
from tortoise.query_utils import Prefetch
from tortoise.queryset import QuerySet from tortoise.queryset import QuerySet
from tortoise.signals import ( from tortoise.signals import (
post_delete, post_delete,

View file

@ -1 +0,0 @@
from tortoise.functions import *

View file

@ -31,18 +31,6 @@ UUID.__get_pydantic_core_schema__ = classmethod(__uuid_schema_monkey_patch)
class ModelMeta(type(TortoiseModel)): class ModelMeta(type(TortoiseModel)):
def __new__(mcls, name, bases, attrs): def __new__(mcls, name, bases, attrs):
meta = attrs.get("Meta", None)
if meta is None:
class Meta:
pass
meta = Meta
attrs["Meta"] = meta
if not hasattr(meta, "table"):
# Use first part of module as app_label
app_label = attrs.get("__module__", "").replace("ohmyapi.builtin.", "ohmyapi_").split(".")[0]
setattr(meta, "table", f"{app_label}_{name.lower()}")
# Grab the Schema class for further processing. # Grab the Schema class for further processing.
schema_opts = attrs.get("Schema", None) schema_opts = attrs.get("Schema", None)

View file

@ -15,12 +15,12 @@ CORS_CONFIG: Dict[str, Any] = getattr(settings, "MIDDLEWARE_CORS", {})
if not isinstance(CORS_CONFIG, dict): if not isinstance(CORS_CONFIG, dict):
raise ValueError("MIDDLEWARE_CORS must be of type dict") raise ValueError("MIDDLEWARE_CORS must be of type dict")
middleware = ( middleware = [
CORSMiddleware, (CORSMiddleware, {
{
"allow_origins": CORS_CONFIG.get("ALLOW_ORIGINS", DEFAULT_ORIGINS), "allow_origins": CORS_CONFIG.get("ALLOW_ORIGINS", DEFAULT_ORIGINS),
"allow_credentials": CORS_CONFIG.get("ALLOW_CREDENTIALS", DEFAULT_CREDENTIALS), "allow_credentials": CORS_CONFIG.get("ALLOW_CREDENTIALS", DEFAULT_CREDENTIALS),
"allow_methods": CORS_CONFIG.get("ALLOW_METHODS", DEFAULT_METHODS), "allow_methods": CORS_CONFIG.get("ALLOW_METHODS", DEFAULT_METHODS),
"allow_headers": CORS_CONFIG.get("ALLOW_HEADERS", DEFAULT_HEADERS), "allow_headers": CORS_CONFIG.get("ALLOW_HEADERS", DEFAULT_HEADERS),
} }),
) ]