fastloom 0.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastloom-0.3.3/PKG-INFO +51 -0
- fastloom-0.3.3/README.md +6 -0
- fastloom-0.3.3/fastloom/__init__.py +0 -0
- fastloom-0.3.3/fastloom/auth/__init__.py +5 -0
- fastloom-0.3.3/fastloom/auth/depends.py +58 -0
- fastloom-0.3.3/fastloom/auth/introspect/__init__.py +0 -0
- fastloom-0.3.3/fastloom/auth/introspect/depends.py +79 -0
- fastloom-0.3.3/fastloom/auth/introspect/schema.py +5 -0
- fastloom-0.3.3/fastloom/auth/protocols.py +12 -0
- fastloom-0.3.3/fastloom/auth/schemas.py +29 -0
- fastloom-0.3.3/fastloom/cache/__init__.py +0 -0
- fastloom-0.3.3/fastloom/cache/base.py +29 -0
- fastloom-0.3.3/fastloom/cache/lifehooks.py +28 -0
- fastloom-0.3.3/fastloom/cache/settings.py +9 -0
- fastloom-0.3.3/fastloom/crypto.py +20 -0
- fastloom-0.3.3/fastloom/date.py +24 -0
- fastloom-0.3.3/fastloom/db/__init__.py +0 -0
- fastloom-0.3.3/fastloom/db/healthcheck.py +22 -0
- fastloom-0.3.3/fastloom/db/lifehooks.py +83 -0
- fastloom-0.3.3/fastloom/db/schemas.py +91 -0
- fastloom-0.3.3/fastloom/db/settings.py +6 -0
- fastloom-0.3.3/fastloom/db/signals.py +138 -0
- fastloom-0.3.3/fastloom/db/transactions.py +52 -0
- fastloom-0.3.3/fastloom/file/__init__.py +0 -0
- fastloom-0.3.3/fastloom/file/models.py +46 -0
- fastloom-0.3.3/fastloom/file/schema.py +112 -0
- fastloom-0.3.3/fastloom/file/signals.py +32 -0
- fastloom-0.3.3/fastloom/file/utils.py +19 -0
- fastloom-0.3.3/fastloom/healthcheck/__init__.py +0 -0
- fastloom-0.3.3/fastloom/healthcheck/handler.py +22 -0
- fastloom-0.3.3/fastloom/i18n/__init__.py +0 -0
- fastloom-0.3.3/fastloom/i18n/base.py +65 -0
- fastloom-0.3.3/fastloom/i18n/handler.py +70 -0
- fastloom-0.3.3/fastloom/i18n/settings.py +7 -0
- fastloom-0.3.3/fastloom/i18n/types.py +24 -0
- fastloom-0.3.3/fastloom/launcher/__init__.py +0 -0
- fastloom-0.3.3/fastloom/launcher/main.py +99 -0
- fastloom-0.3.3/fastloom/launcher/schemas.py +158 -0
- fastloom-0.3.3/fastloom/launcher/settings.py +13 -0
- fastloom-0.3.3/fastloom/launcher/utils.py +86 -0
- fastloom-0.3.3/fastloom/meta.py +29 -0
- fastloom-0.3.3/fastloom/monitoring.py +295 -0
- fastloom-0.3.3/fastloom/observability/__init__.py +0 -0
- fastloom-0.3.3/fastloom/observability/settings.py +10 -0
- fastloom-0.3.3/fastloom/py.typed +0 -0
- fastloom-0.3.3/fastloom/settings/__init__.py +0 -0
- fastloom-0.3.3/fastloom/settings/base.py +39 -0
- fastloom-0.3.3/fastloom/settings/utils.py +14 -0
- fastloom-0.3.3/fastloom/signals/__init__.py +0 -0
- fastloom-0.3.3/fastloom/signals/depends.py +304 -0
- fastloom-0.3.3/fastloom/signals/healthcheck.py +21 -0
- fastloom-0.3.3/fastloom/signals/lifehooks.py +34 -0
- fastloom-0.3.3/fastloom/signals/middlewares.py +84 -0
- fastloom-0.3.3/fastloom/signals/settings.py +5 -0
- fastloom-0.3.3/fastloom/tenant/__init__.py +3 -0
- fastloom-0.3.3/fastloom/tenant/base/__init__.py +0 -0
- fastloom-0.3.3/fastloom/tenant/base/utils.py +8 -0
- fastloom-0.3.3/fastloom/tenant/depends.py +223 -0
- fastloom-0.3.3/fastloom/tenant/handler.py +63 -0
- fastloom-0.3.3/fastloom/tenant/protocols.py +15 -0
- fastloom-0.3.3/fastloom/tenant/schemas.py +15 -0
- fastloom-0.3.3/fastloom/tenant/settings.py +212 -0
- fastloom-0.3.3/fastloom/tenant/utils.py +92 -0
- fastloom-0.3.3/fastloom/tests.py +50 -0
- fastloom-0.3.3/fastloom/types.py +57 -0
- fastloom-0.3.3/fastloom/utils.py +27 -0
- fastloom-0.3.3/pyproject.toml +98 -0
fastloom-0.3.3/PKG-INFO
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: fastloom
|
|
3
|
+
Version: 0.3.3
|
|
4
|
+
Summary: Core package
|
|
5
|
+
Requires-Python: >=3.12,<3.13
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
8
|
+
Provides-Extra: celery
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Provides-Extra: fastapi
|
|
11
|
+
Provides-Extra: httpx
|
|
12
|
+
Provides-Extra: mongodb
|
|
13
|
+
Provides-Extra: openai
|
|
14
|
+
Provides-Extra: rabbit
|
|
15
|
+
Provides-Extra: redis
|
|
16
|
+
Provides-Extra: requests
|
|
17
|
+
Requires-Dist: babel (>=2.17.0,<3.0.0)
|
|
18
|
+
Requires-Dist: beanie (>=2.0.0,<3.0.0) ; extra == "mongodb"
|
|
19
|
+
Requires-Dist: celery (>=5.5.3,<6.0.0) ; extra == "celery"
|
|
20
|
+
Requires-Dist: fastapi (>=0,<1) ; extra == "fastapi" or extra == "rabbit"
|
|
21
|
+
Requires-Dist: faststream[otel,rabbit] (>=0.5.28,<0.6.0) ; extra == "rabbit"
|
|
22
|
+
Requires-Dist: httpx (>=0.28.0,<0.29.0) ; extra == "httpx"
|
|
23
|
+
Requires-Dist: ipykernel (>=6.30.0,<7.0.0) ; extra == "dev"
|
|
24
|
+
Requires-Dist: jdatetime (>=4.1.1,<5.0.0)
|
|
25
|
+
Requires-Dist: jinja2 (>=3.1.6,<4.0.0)
|
|
26
|
+
Requires-Dist: logfire[asgi,celery,fastapi,httpx,openai,pymongo,redis,requests,system-metrics] (>=4.0.0,<5.0.0)
|
|
27
|
+
Requires-Dist: mypy (>=1.17.0,<2.0.0) ; extra == "dev"
|
|
28
|
+
Requires-Dist: openai (>=1,<2) ; extra == "openai"
|
|
29
|
+
Requires-Dist: opentelemetry-distro
|
|
30
|
+
Requires-Dist: opentelemetry-exporter-otlp
|
|
31
|
+
Requires-Dist: opentelemetry-instrumentation-aio-pika ; extra == "rabbit"
|
|
32
|
+
Requires-Dist: orjson (==3.10.14)
|
|
33
|
+
Requires-Dist: pre-commit (>=4.2.0,<5.0.0) ; extra == "dev"
|
|
34
|
+
Requires-Dist: pydantic[email] (>=2.11,<3.0)
|
|
35
|
+
Requires-Dist: python-jose (>=3.5.0,<4.0.0)
|
|
36
|
+
Requires-Dist: python-multipart ; extra == "fastapi"
|
|
37
|
+
Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
|
|
38
|
+
Requires-Dist: redis-om (>=1.0.3b,<2.0.0) ; extra == "redis"
|
|
39
|
+
Requires-Dist: requests (>=2.32.0,<3.0.0) ; extra == "requests"
|
|
40
|
+
Requires-Dist: ruff (>=0.12.5,<0.13.0) ; extra == "dev"
|
|
41
|
+
Requires-Dist: sentry-sdk[fastapi] (>=2.0.0,<3.0.0)
|
|
42
|
+
Requires-Dist: uvicorn (>=0.35.0,<0.36.0) ; extra == "fastapi" or extra == "rabbit"
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# Core bluprint Core Package
|
|
46
|
+
## Install on other projects
|
|
47
|
+
1. Run `poetry config http-basic.microservice-registry <your username> <your personal token>`
|
|
48
|
+
|
|
49
|
+
## Committing updates
|
|
50
|
+
1. Run `poetry version --next-phase patch`
|
|
51
|
+
|
fastloom-0.3.3/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from collections.abc import Callable, Coroutine
|
|
2
|
+
from typing import Annotated, Any
|
|
3
|
+
|
|
4
|
+
from fastapi import Depends
|
|
5
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
6
|
+
from jose.jwt import get_unverified_claims
|
|
7
|
+
|
|
8
|
+
from fastloom.auth import Claims
|
|
9
|
+
from fastloom.auth.protocols import OAuth2Settings
|
|
10
|
+
from fastloom.auth.schemas import UserClaims
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OptionalJWTAuth:
|
|
14
|
+
settings: OAuth2Settings
|
|
15
|
+
_oauth2_schema: OAuth2PasswordBearer | None = None
|
|
16
|
+
|
|
17
|
+
def __init__(self, settings: OAuth2Settings):
|
|
18
|
+
self.settings = settings
|
|
19
|
+
self._oauth2_schema = OAuth2PasswordBearer(
|
|
20
|
+
str(settings.IAM_TOKEN_URL), auto_error=False
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def _parse_token(cls, token: str) -> UserClaims:
|
|
25
|
+
return UserClaims.model_validate(get_unverified_claims(token))
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def get_claims(
|
|
29
|
+
self,
|
|
30
|
+
) -> Callable[..., Coroutine[Any, Any, UserClaims | None]]:
|
|
31
|
+
async def _inner(
|
|
32
|
+
token: Annotated[str | None, Depends(self._oauth2_schema)],
|
|
33
|
+
) -> UserClaims | None:
|
|
34
|
+
if token is None:
|
|
35
|
+
return None
|
|
36
|
+
claims = self._parse_token(token)
|
|
37
|
+
Claims.set(claims)
|
|
38
|
+
return claims
|
|
39
|
+
|
|
40
|
+
return _inner
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class JWTAuth(OptionalJWTAuth):
|
|
44
|
+
def __init__(self, settings: OAuth2Settings):
|
|
45
|
+
super().__init__(settings)
|
|
46
|
+
assert self._oauth2_schema is not None
|
|
47
|
+
self._oauth2_schema.auto_error = True
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def get_claims(self) -> Callable[..., Coroutine[Any, Any, UserClaims]]:
|
|
51
|
+
async def _inner(
|
|
52
|
+
token: Annotated[str, Depends(self._oauth2_schema)],
|
|
53
|
+
) -> UserClaims:
|
|
54
|
+
claims = self._parse_token(token)
|
|
55
|
+
Claims.set(claims)
|
|
56
|
+
return claims
|
|
57
|
+
|
|
58
|
+
return _inner
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from collections.abc import Callable, Coroutine
|
|
2
|
+
from typing import Annotated, Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from fastapi import Depends, HTTPException, Request
|
|
6
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
7
|
+
|
|
8
|
+
from fastloom.auth.depends import JWTAuth, OptionalJWTAuth
|
|
9
|
+
from fastloom.auth.introspect.schema import IntrospectionResponse
|
|
10
|
+
from fastloom.auth.protocols import SidecarSettings
|
|
11
|
+
from fastloom.auth.schemas import UserClaims
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OptionalVerifiedAuth(OptionalJWTAuth):
|
|
15
|
+
settings: SidecarSettings
|
|
16
|
+
_oauth2_schema: OAuth2PasswordBearer | None = None
|
|
17
|
+
|
|
18
|
+
def __init__(self, settings: SidecarSettings):
|
|
19
|
+
super().__init__(settings)
|
|
20
|
+
|
|
21
|
+
async def _introspect(
|
|
22
|
+
self, token: Annotated[str, Depends(_oauth2_schema)]
|
|
23
|
+
):
|
|
24
|
+
async with httpx.AsyncClient() as client:
|
|
25
|
+
response: httpx.Response = await client.post(
|
|
26
|
+
f"{self.settings.IAM_SIDECAR_URL}/introspect",
|
|
27
|
+
json=dict(token=token),
|
|
28
|
+
)
|
|
29
|
+
if response.status_code != 200:
|
|
30
|
+
raise HTTPException(status_code=403, detail=response.text)
|
|
31
|
+
data = IntrospectionResponse.model_validate(response.json())
|
|
32
|
+
if not data.active:
|
|
33
|
+
raise HTTPException(status_code=403, detail="Inactive token")
|
|
34
|
+
|
|
35
|
+
async def _acl(
|
|
36
|
+
self, request: Request, token: Annotated[str, Depends(_oauth2_schema)]
|
|
37
|
+
) -> None:
|
|
38
|
+
async with httpx.AsyncClient() as client:
|
|
39
|
+
response: httpx.Response = await client.post(
|
|
40
|
+
url=f"{self.settings.IAM_SIDECAR_URL}/acl",
|
|
41
|
+
json={
|
|
42
|
+
"token": token,
|
|
43
|
+
"endpoint": request.url.path,
|
|
44
|
+
"method": request.method,
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if response.status_code != 200:
|
|
49
|
+
raise HTTPException(status_code=403, detail=response.text)
|
|
50
|
+
if not response.json():
|
|
51
|
+
raise HTTPException(status_code=403)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def get_claims(
|
|
55
|
+
self,
|
|
56
|
+
) -> Callable[..., Coroutine[Any, Any, UserClaims | None]]:
|
|
57
|
+
async def _inner(
|
|
58
|
+
request: Request, token: str | None = Depends(self._oauth2_schema)
|
|
59
|
+
) -> UserClaims | None:
|
|
60
|
+
if token is None:
|
|
61
|
+
return None
|
|
62
|
+
await self._introspect(token)
|
|
63
|
+
await self._acl(request, token)
|
|
64
|
+
return await super(OptionalVerifiedAuth, self).get_claims(token)
|
|
65
|
+
|
|
66
|
+
return _inner
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class VerifiedAuth(JWTAuth, OptionalVerifiedAuth):
|
|
70
|
+
@property
|
|
71
|
+
def get_claims(self) -> Callable[..., Coroutine[Any, Any, UserClaims]]:
|
|
72
|
+
async def _inner(
|
|
73
|
+
request: Request, token: str = Depends(self._oauth2_schema)
|
|
74
|
+
) -> UserClaims:
|
|
75
|
+
await self._introspect(token)
|
|
76
|
+
await self._acl(request, token)
|
|
77
|
+
return await super(VerifiedAuth, self).get_claims(token)
|
|
78
|
+
|
|
79
|
+
return _inner
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field, computed_field, field_validator
|
|
2
|
+
|
|
3
|
+
ADMIN_ROLE = "ADMIN"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Role(BaseModel):
|
|
7
|
+
name: str
|
|
8
|
+
users: list[str] | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UserClaims(BaseModel):
|
|
12
|
+
tenant: str = Field(alias="owner")
|
|
13
|
+
id: str
|
|
14
|
+
username: str = Field(..., validation_alias="name")
|
|
15
|
+
email: str | None = None
|
|
16
|
+
phone: str | None = None
|
|
17
|
+
roles: list[Role] = Field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
@field_validator("roles", mode="before")
|
|
20
|
+
@classmethod
|
|
21
|
+
def validate_roles(cls, v: list[Role] | None) -> list[Role]:
|
|
22
|
+
if not v:
|
|
23
|
+
return []
|
|
24
|
+
return v
|
|
25
|
+
|
|
26
|
+
@computed_field # type: ignore[misc]
|
|
27
|
+
@property
|
|
28
|
+
def is_admin(self) -> bool:
|
|
29
|
+
return any(role.name == ADMIN_ROLE for role in self.roles or [])
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from aredis_om import Field, JsonModel
|
|
2
|
+
|
|
3
|
+
from fastloom.cache.lifehooks import RedisHandler
|
|
4
|
+
|
|
5
|
+
RedisHandler()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseCache(JsonModel):
|
|
9
|
+
class Meta:
|
|
10
|
+
global_key_prefix = "cache"
|
|
11
|
+
database = RedisHandler.redis
|
|
12
|
+
model_key_prefix = "base"
|
|
13
|
+
# ^should be overriden in sub
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
async def invalidate(self):
|
|
17
|
+
return await self.expire(0)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BaseTenantSettingCache(BaseCache):
|
|
21
|
+
id: str = Field(primary_key=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HostTenantMapping(BaseCache, index=True): # type: ignore[call-arg]
|
|
25
|
+
host: str = Field(primary_key=True)
|
|
26
|
+
tenant: str = Field(index=True)
|
|
27
|
+
|
|
28
|
+
class Meta:
|
|
29
|
+
model_key_prefix = "host_mapping"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from contextlib import suppress
|
|
2
|
+
from os import getenv
|
|
3
|
+
|
|
4
|
+
from aredis_om import get_redis_connection
|
|
5
|
+
from redis import Redis
|
|
6
|
+
from redis.exceptions import ConnectionError
|
|
7
|
+
|
|
8
|
+
from fastloom.cache.settings import RedisSettings
|
|
9
|
+
from fastloom.meta import SelfSustaining
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RedisHandler(SelfSustaining):
|
|
13
|
+
enabled: bool
|
|
14
|
+
redis: Redis
|
|
15
|
+
sync_redis: Redis
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
super().__init__()
|
|
19
|
+
self.enabled = False
|
|
20
|
+
settings = RedisSettings.model_validate(
|
|
21
|
+
dict(redis_url=getenv("REDIS_OM_URL"))
|
|
22
|
+
if getenv("REDIS_OM_URL")
|
|
23
|
+
else {}
|
|
24
|
+
)
|
|
25
|
+
self.redis = get_redis_connection(url=str(settings.redis_url))
|
|
26
|
+
self.sync_redis = Redis.from_url(url=str(settings.redis_url))
|
|
27
|
+
with suppress(ConnectionError):
|
|
28
|
+
self.enabled = self.sync_redis
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def generate_token(length: int):
|
|
5
|
+
"""
|
|
6
|
+
Generates a n-digits numeral token, that used for OTP
|
|
7
|
+
"""
|
|
8
|
+
min_value = 10 ** (length - 1)
|
|
9
|
+
max_value = 10**length - 1
|
|
10
|
+
return str(secrets.randbelow(max_value - min_value + 1) + min_value).zfill(
|
|
11
|
+
length
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate_alphanumeric_token(length: int):
|
|
16
|
+
"""
|
|
17
|
+
Generates a n-digits alphanumeric token, that used for OTP
|
|
18
|
+
"""
|
|
19
|
+
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
20
|
+
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from datetime import date, datetime, time
|
|
2
|
+
from zoneinfo import ZoneInfo
|
|
3
|
+
|
|
4
|
+
import jdatetime # type: ignore[import-untyped]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def datetime_to_jalali(dt: datetime, date_only: bool = False) -> str:
|
|
8
|
+
dt = dt.astimezone(ZoneInfo("Asia/Tehran"))
|
|
9
|
+
return jdatetime.datetime.fromgregorian(datetime=dt).strftime(
|
|
10
|
+
"%Y/%m/%d" if date_only else "%Y/%m/%d - %H:%M"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def utcnow() -> datetime:
|
|
15
|
+
return datetime.now(ZoneInfo("UTC"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def datetime_to_timestamp(value: datetime) -> int:
|
|
19
|
+
dt_utc = value.replace(tzinfo=ZoneInfo("UTC"))
|
|
20
|
+
return int(dt_utc.timestamp())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_zero_time() -> datetime:
|
|
24
|
+
return datetime.combine(date.today(), time())
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from collections.abc import Callable, Coroutine
|
|
2
|
+
from functools import partial
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MongoConnectionError(Exception): ...
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def check_mongo_connection(mongo_uri: str) -> None:
|
|
10
|
+
from pymongo import AsyncMongoClient
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
client: AsyncMongoClient = AsyncMongoClient(mongo_uri, timeoutms=2000)
|
|
14
|
+
await client.admin.command("ping")
|
|
15
|
+
except Exception as er:
|
|
16
|
+
raise MongoConnectionError(f"MongoDB connection error: {er}") from er
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_healthcheck(
|
|
20
|
+
mongo_uri: str,
|
|
21
|
+
) -> Callable[[], Coroutine[Any, Any, None]]:
|
|
22
|
+
return partial(check_mongo_connection, mongo_uri=mongo_uri)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import pkgutil
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
from itertools import chain
|
|
5
|
+
from types import ModuleType
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from beanie import Document, UnionDoc, View
|
|
10
|
+
else:
|
|
11
|
+
try:
|
|
12
|
+
from beanie import Document, UnionDoc, View
|
|
13
|
+
except ImportError:
|
|
14
|
+
from pydantic import (
|
|
15
|
+
BaseModel as Document,
|
|
16
|
+
)
|
|
17
|
+
from pydantic import (
|
|
18
|
+
BaseModel as UnionDoc,
|
|
19
|
+
)
|
|
20
|
+
from pydantic import (
|
|
21
|
+
BaseModel as View,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def get_mongo_client(mongo_uri: str):
|
|
26
|
+
from pymongo import AsyncMongoClient
|
|
27
|
+
|
|
28
|
+
return AsyncMongoClient(
|
|
29
|
+
mongo_uri,
|
|
30
|
+
tz_aware=True,
|
|
31
|
+
connectTimeoutMS=1000,
|
|
32
|
+
serverSelectionTimeoutMS=5000,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_models(
|
|
37
|
+
module: ModuleType,
|
|
38
|
+
) -> list[type[Document] | type[UnionDoc] | type[View]]:
|
|
39
|
+
if (
|
|
40
|
+
module.__spec__ is None
|
|
41
|
+
or not module.__spec__.submodule_search_locations
|
|
42
|
+
):
|
|
43
|
+
return [
|
|
44
|
+
x
|
|
45
|
+
for x in vars(module).values()
|
|
46
|
+
if isinstance(x, type)
|
|
47
|
+
and issubclass(x, Document | UnionDoc | View)
|
|
48
|
+
and x not in [Document, UnionDoc, View]
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
return list(
|
|
52
|
+
chain.from_iterable(
|
|
53
|
+
get_models(import_module(f"{module.__name__}.{i.name}"))
|
|
54
|
+
for i in pkgutil.iter_modules(module.__path__)
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def init_db(
|
|
60
|
+
database_name: str,
|
|
61
|
+
models: Sequence[type[Document] | type[UnionDoc] | type[View] | str],
|
|
62
|
+
mongo_uri: str,
|
|
63
|
+
):
|
|
64
|
+
from beanie import init_beanie
|
|
65
|
+
|
|
66
|
+
client = await get_mongo_client(mongo_uri)
|
|
67
|
+
db = client[database_name]
|
|
68
|
+
await init_beanie(db, document_models=models, recreate_views=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def destroy_db(
|
|
72
|
+
database_name: str,
|
|
73
|
+
models: list[Document],
|
|
74
|
+
mongo_uri: str,
|
|
75
|
+
drop_database: bool = False,
|
|
76
|
+
):
|
|
77
|
+
client = await get_mongo_client(mongo_uri)
|
|
78
|
+
db = client[database_name]
|
|
79
|
+
if not drop_database:
|
|
80
|
+
for model in models[1:]: # Skip pre-populated Province collection
|
|
81
|
+
await db.drop_collection(model.Settings.name)
|
|
82
|
+
else:
|
|
83
|
+
await client.drop_database(database_name)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Self
|
|
3
|
+
|
|
4
|
+
import bson
|
|
5
|
+
from beanie import (
|
|
6
|
+
Document,
|
|
7
|
+
Insert,
|
|
8
|
+
PydanticObjectId,
|
|
9
|
+
Replace,
|
|
10
|
+
SaveChanges,
|
|
11
|
+
Update,
|
|
12
|
+
before_event,
|
|
13
|
+
)
|
|
14
|
+
from fastapi import HTTPException, status
|
|
15
|
+
from pydantic import BaseModel, Field, computed_field, field_validator
|
|
16
|
+
|
|
17
|
+
from fastloom.date import utcnow
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CreatedAtSchema(BaseModel):
|
|
21
|
+
created_at: datetime = Field(default_factory=utcnow)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CreatedUpdatedAtSchema(CreatedAtSchema):
|
|
25
|
+
"""
|
|
26
|
+
ONLY use this mixin in `beanie.Document` models since it uses
|
|
27
|
+
@before_event decorator
|
|
28
|
+
|
|
29
|
+
NOTE: `updated_at` doesn't get updated when `update_many` is called
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
updated_at: datetime | None = Field(default_factory=utcnow)
|
|
33
|
+
# TODO ^ it shouldn't ideally be None, but some models used to save null
|
|
34
|
+
# so first we have to make sure we cleared db from all such instances
|
|
35
|
+
|
|
36
|
+
@before_event(Insert, Replace, SaveChanges, Update)
|
|
37
|
+
async def update_updated_at(self):
|
|
38
|
+
self.updated_at = utcnow()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BaseDocument(Document):
|
|
42
|
+
id: PydanticObjectId = Field(default_factory=bson.ObjectId, alias="_id") # type: ignore[assignment] # noqa
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
async def get_or_404(cls, id: PydanticObjectId) -> Self:
|
|
46
|
+
obj: Self | None = await cls.get(id)
|
|
47
|
+
if obj is None:
|
|
48
|
+
raise HTTPException(
|
|
49
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
50
|
+
detail=f"{cls.__name__} not found",
|
|
51
|
+
)
|
|
52
|
+
return obj
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
async def find_one_or_404(cls, *args, **kwargs) -> Self:
|
|
56
|
+
obj: Self | None = await cls.find_one(*args, **kwargs)
|
|
57
|
+
if obj is None:
|
|
58
|
+
raise HTTPException(
|
|
59
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
60
|
+
detail=f"{cls.__name__} not found",
|
|
61
|
+
)
|
|
62
|
+
return obj
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class BasePaginationQuery(BaseModel):
|
|
66
|
+
offset: int | None = Field(None, ge=0)
|
|
67
|
+
limit: int | None = Field(None, ge=0)
|
|
68
|
+
|
|
69
|
+
@field_validator("limit", mode="after")
|
|
70
|
+
@classmethod
|
|
71
|
+
def convert_zero_limit(cls, v: int | None) -> int | None:
|
|
72
|
+
return v or None
|
|
73
|
+
|
|
74
|
+
@computed_field # type: ignore[misc]
|
|
75
|
+
@property
|
|
76
|
+
def skip(self) -> int | None:
|
|
77
|
+
if self.limit and self.offset is not None:
|
|
78
|
+
return self.limit * self.offset
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PaginatedResponse[T](BaseModel):
|
|
83
|
+
data: list[T] = Field(default_factory=list)
|
|
84
|
+
count: int = Field(default=0, ge=0)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class BaseTenantSettingsDocument(Document, CreatedUpdatedAtSchema):
|
|
88
|
+
id: str # type: ignore[assignment]
|
|
89
|
+
|
|
90
|
+
class Settings:
|
|
91
|
+
name = "settings"
|