Compare commits
20 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5691f3133 | ||
|
|
5f80a7a86f | ||
|
|
b588ebcf8a | ||
|
|
a9b88d87d6 | ||
|
|
7163fe778e | ||
|
|
458ffc6b2c | ||
|
|
22ca522615 | ||
|
|
8c2cf01f40 | ||
|
|
9d2e284da3 | ||
|
|
ed30291a4c | ||
|
|
31f4da773c | ||
|
|
715b7a030a | ||
|
|
4a5bafd889 | ||
|
|
b50cbc4341 | ||
|
|
10681cc15b | ||
|
|
58f1387aaf | ||
|
|
6b87bfeefb | ||
|
|
812e89ede9 | ||
|
|
e25c9d1715 | ||
|
|
d494396728 |
11 changed files with 187 additions and 66 deletions
|
|
@ -1,7 +1,7 @@
|
|||
# Apps
|
||||
|
||||
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` and `Group` model and provides API endpoints for JWT authentication.
|
||||
For example, OhMyAPI comes bundled with an `auth` app that carries a `User` model and provides API endpoints for JWT authentication.
|
||||
|
||||
Apps help organizing projects by isolating individual components (or "features") from one another.
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,6 @@ router = APIRouter()
|
|||
|
||||
|
||||
@router.get("/")
|
||||
def get(user: auth.User = Depends(permissions.required_authenticated)):
|
||||
def get(user: auth.User = Depends(permissions.require_authenticated)):
|
||||
...
|
||||
```
|
||||
|
|
|
|||
113
poetry.lock
generated
113
poetry.lock
generated
|
|
@ -1,4 +1,4 @@
|
|||
# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aerich"
|
||||
|
|
@ -62,7 +62,7 @@ version = "4.11.0"
|
|||
description = "High-level concurrency and networking framework on top of asyncio or Trio"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"},
|
||||
{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."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
|
||||
{file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
|
||||
|
|
@ -543,19 +543,66 @@ version = "0.16.0"
|
|||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
|
||||
{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]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
|
||||
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
|
||||
|
|
@ -564,6 +611,18 @@ files = [
|
|||
[package.extras]
|
||||
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]]
|
||||
name = "ipython"
|
||||
version = "9.6.0"
|
||||
|
|
@ -1024,6 +1083,22 @@ 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)"]
|
||||
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]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.52"
|
||||
|
|
@ -1260,6 +1335,28 @@ files = [
|
|||
{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]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
|
|
@ -1488,7 +1585,7 @@ version = "1.3.1"
|
|||
description = "Sniff out which async library your code is running under"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
|
||||
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
|
||||
|
|
@ -1604,7 +1701,7 @@ files = [
|
|||
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||
]
|
||||
markers = {dev = "python_version == \"3.11\""}
|
||||
markers = {dev = "python_version < \"3.13\""}
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
|
|
@ -1719,4 +1816,4 @@ auth = ["argon2-cffi", "crypto", "passlib", "pyjwt", "python-multipart"]
|
|||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11"
|
||||
content-hash = "3d301460081dada359d425d69feefc63c1e5135aa64b6f000f554bfc1231febd"
|
||||
content-hash = "cc1604995d3b73ee302e63731dd300ea17c4d95d0cfc6c386626dd9a9f60e8a7"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "ohmyapi"
|
||||
version = "0.5.0"
|
||||
version = "0.6.2"
|
||||
description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations"
|
||||
license = "MIT"
|
||||
keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"]
|
||||
|
|
@ -30,6 +30,8 @@ ipython = ">=9.5.0,<10.0.0"
|
|||
black = "^25.9.0"
|
||||
isort = "^6.0.1"
|
||||
mkdocs = "^1.6.1"
|
||||
pytest = "^8.4.2"
|
||||
httpx = "^0.28.1"
|
||||
|
||||
[project.optional-dependencies]
|
||||
auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"]
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
__VERSION__ = "0.5.0"
|
||||
__VERSION__ = "0.6.2"
|
||||
|
|
|
|||
|
|
@ -1,53 +1,39 @@
|
|||
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.router import HTTPException
|
||||
|
||||
import settings
|
||||
from .utils import hmac_hash
|
||||
|
||||
from datetime import datetime
|
||||
from passlib.context import CryptContext
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
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):
|
||||
id: UUID = field.data.UUIDField(pk=True)
|
||||
username: str = field.CharField(max_length=150, unique=True)
|
||||
email_hash: Optional[str] = field.CharField(max_length=255, unique=True, index=True)
|
||||
email_hash: str = field.CharField(max_length=255, unique=True, index=True)
|
||||
password_hash: str = field.CharField(max_length=128)
|
||||
is_admin: bool = field.BooleanField(default=False)
|
||||
is_staff: bool = field.BooleanField(default=False)
|
||||
groups: field.ManyToManyRelation[Group] = field.ManyToManyField(
|
||||
"ohmyapi_auth.Group", related_name="users", through="usergroups"
|
||||
)
|
||||
created_at: datetime = field.DatetimeField(auto_now_add=True)
|
||||
updated_at: datetime = field.DatetimeField(auto_now=True)
|
||||
|
||||
class Schema:
|
||||
exclude = ["password_hash", "email_hash"]
|
||||
include = {
|
||||
"id",
|
||||
"username",
|
||||
"is_admin",
|
||||
"is_staff"
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
|
||||
def __str__(self):
|
||||
fields = {
|
||||
'username': self.username if self.username else "-",
|
||||
'username': self.username,
|
||||
'is_admin': 'y' if self.is_admin else 'n',
|
||||
'is_staff': 'y' if self.is_staff else 'n',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request, st
|
|||
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 Group, User
|
||||
from ohmyapi.builtin.auth.models import User
|
||||
|
||||
import jwt
|
||||
import settings
|
||||
|
|
@ -52,7 +53,6 @@ class Claims(BaseModel):
|
|||
type: str
|
||||
sub: str
|
||||
user: ClaimsUser
|
||||
roles: List[str]
|
||||
exp: str
|
||||
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ class TokenType(str, Enum):
|
|||
refresh = "refresh"
|
||||
|
||||
|
||||
def claims(token_type: TokenType, user: User, groups: List[Group] = []) -> Claims:
|
||||
def claims(token_type: TokenType, user: User = []) -> Claims:
|
||||
return Claims(
|
||||
type=token_type,
|
||||
sub=str(user.id),
|
||||
|
|
@ -88,7 +88,6 @@ def claims(token_type: TokenType, user: User, groups: List[Group] = []) -> Claim
|
|||
is_admin=user.is_admin,
|
||||
is_staff=user.is_staff,
|
||||
),
|
||||
roles=[g.name for g in groups],
|
||||
exp="",
|
||||
)
|
||||
|
||||
|
|
@ -200,17 +199,25 @@ async def login(form_data: LoginRequest = Body(...)):
|
|||
)
|
||||
|
||||
|
||||
class TokenRefresh(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=AccessToken)
|
||||
async def refresh_token(refresh_token: str):
|
||||
async def refresh_token(refresh_token: TokenRefresh = Body(...)):
|
||||
"""Exchange refresh token for new access token."""
|
||||
payload = decode_token(refresh_token)
|
||||
payload = decode_token(refresh_token.refresh_token)
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
user = await User.filter(id=user_id).first()
|
||||
try:
|
||||
user = await User.get(id=user_id)
|
||||
except DoesNotExist:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
|
||||
|
|
@ -219,7 +226,7 @@ async def refresh_token(refresh_token: str):
|
|||
new_access = create_token(
|
||||
claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS
|
||||
)
|
||||
return AccessToken(token_type="bearer", access_token=access_token)
|
||||
return AccessToken(token_type="bearer", access_token=new_access)
|
||||
|
||||
|
||||
@router.get("/introspect", response_model=Dict[str, Any])
|
||||
|
|
|
|||
17
src/ohmyapi/builtin/auth/utils.py
Normal file
17
src/ohmyapi/builtin/auth/utils.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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")
|
||||
|
||||
|
|
@ -165,6 +165,13 @@ def createsuperuser(root: str = "."):
|
|||
)
|
||||
user.set_email(email)
|
||||
user.set_password(password1)
|
||||
asyncio.run(project.init_orm())
|
||||
asyncio.run(user.save())
|
||||
asyncio.run(project.close_orm())
|
||||
|
||||
async def _run():
|
||||
await project.init_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())
|
||||
|
|
|
|||
|
|
@ -342,9 +342,9 @@ class App:
|
|||
except ModuleNotFoundError:
|
||||
return
|
||||
|
||||
getter = getattr(mod, "get", None)
|
||||
if getter is not None:
|
||||
for middleware in getter():
|
||||
installer = getattr(mod, "install", None)
|
||||
if installer is not None:
|
||||
for middleware in installer():
|
||||
self._middlewares.append(middleware)
|
||||
|
||||
def __serialize_route(self, route):
|
||||
|
|
@ -404,10 +404,15 @@ class App:
|
|||
"""
|
||||
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 {
|
||||
"models": [
|
||||
f"{self.name}.{m.__name__}" for m in self._models[f"{self.name}.models"]
|
||||
],
|
||||
"models": models,
|
||||
"middlewares": self.__serialize_middleware(),
|
||||
"routes": self.__serialize_router(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ CORS_CONFIG: Dict[str, Any] = getattr(settings, "MIDDLEWARE_CORS", {})
|
|||
if not isinstance(CORS_CONFIG, dict):
|
||||
raise ValueError("MIDDLEWARE_CORS must be of type dict")
|
||||
|
||||
middleware = [
|
||||
(CORSMiddleware, {
|
||||
middleware = (
|
||||
CORSMiddleware,
|
||||
{
|
||||
"allow_origins": CORS_CONFIG.get("ALLOW_ORIGINS", DEFAULT_ORIGINS),
|
||||
"allow_credentials": CORS_CONFIG.get("ALLOW_CREDENTIALS", DEFAULT_CREDENTIALS),
|
||||
"allow_methods": CORS_CONFIG.get("ALLOW_METHODS", DEFAULT_METHODS),
|
||||
"allow_headers": CORS_CONFIG.get("ALLOW_HEADERS", DEFAULT_HEADERS),
|
||||
}),
|
||||
]
|
||||
|
||||
}
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue