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.
Files changed (67) hide show
  1. fastloom-0.3.3/PKG-INFO +51 -0
  2. fastloom-0.3.3/README.md +6 -0
  3. fastloom-0.3.3/fastloom/__init__.py +0 -0
  4. fastloom-0.3.3/fastloom/auth/__init__.py +5 -0
  5. fastloom-0.3.3/fastloom/auth/depends.py +58 -0
  6. fastloom-0.3.3/fastloom/auth/introspect/__init__.py +0 -0
  7. fastloom-0.3.3/fastloom/auth/introspect/depends.py +79 -0
  8. fastloom-0.3.3/fastloom/auth/introspect/schema.py +5 -0
  9. fastloom-0.3.3/fastloom/auth/protocols.py +12 -0
  10. fastloom-0.3.3/fastloom/auth/schemas.py +29 -0
  11. fastloom-0.3.3/fastloom/cache/__init__.py +0 -0
  12. fastloom-0.3.3/fastloom/cache/base.py +29 -0
  13. fastloom-0.3.3/fastloom/cache/lifehooks.py +28 -0
  14. fastloom-0.3.3/fastloom/cache/settings.py +9 -0
  15. fastloom-0.3.3/fastloom/crypto.py +20 -0
  16. fastloom-0.3.3/fastloom/date.py +24 -0
  17. fastloom-0.3.3/fastloom/db/__init__.py +0 -0
  18. fastloom-0.3.3/fastloom/db/healthcheck.py +22 -0
  19. fastloom-0.3.3/fastloom/db/lifehooks.py +83 -0
  20. fastloom-0.3.3/fastloom/db/schemas.py +91 -0
  21. fastloom-0.3.3/fastloom/db/settings.py +6 -0
  22. fastloom-0.3.3/fastloom/db/signals.py +138 -0
  23. fastloom-0.3.3/fastloom/db/transactions.py +52 -0
  24. fastloom-0.3.3/fastloom/file/__init__.py +0 -0
  25. fastloom-0.3.3/fastloom/file/models.py +46 -0
  26. fastloom-0.3.3/fastloom/file/schema.py +112 -0
  27. fastloom-0.3.3/fastloom/file/signals.py +32 -0
  28. fastloom-0.3.3/fastloom/file/utils.py +19 -0
  29. fastloom-0.3.3/fastloom/healthcheck/__init__.py +0 -0
  30. fastloom-0.3.3/fastloom/healthcheck/handler.py +22 -0
  31. fastloom-0.3.3/fastloom/i18n/__init__.py +0 -0
  32. fastloom-0.3.3/fastloom/i18n/base.py +65 -0
  33. fastloom-0.3.3/fastloom/i18n/handler.py +70 -0
  34. fastloom-0.3.3/fastloom/i18n/settings.py +7 -0
  35. fastloom-0.3.3/fastloom/i18n/types.py +24 -0
  36. fastloom-0.3.3/fastloom/launcher/__init__.py +0 -0
  37. fastloom-0.3.3/fastloom/launcher/main.py +99 -0
  38. fastloom-0.3.3/fastloom/launcher/schemas.py +158 -0
  39. fastloom-0.3.3/fastloom/launcher/settings.py +13 -0
  40. fastloom-0.3.3/fastloom/launcher/utils.py +86 -0
  41. fastloom-0.3.3/fastloom/meta.py +29 -0
  42. fastloom-0.3.3/fastloom/monitoring.py +295 -0
  43. fastloom-0.3.3/fastloom/observability/__init__.py +0 -0
  44. fastloom-0.3.3/fastloom/observability/settings.py +10 -0
  45. fastloom-0.3.3/fastloom/py.typed +0 -0
  46. fastloom-0.3.3/fastloom/settings/__init__.py +0 -0
  47. fastloom-0.3.3/fastloom/settings/base.py +39 -0
  48. fastloom-0.3.3/fastloom/settings/utils.py +14 -0
  49. fastloom-0.3.3/fastloom/signals/__init__.py +0 -0
  50. fastloom-0.3.3/fastloom/signals/depends.py +304 -0
  51. fastloom-0.3.3/fastloom/signals/healthcheck.py +21 -0
  52. fastloom-0.3.3/fastloom/signals/lifehooks.py +34 -0
  53. fastloom-0.3.3/fastloom/signals/middlewares.py +84 -0
  54. fastloom-0.3.3/fastloom/signals/settings.py +5 -0
  55. fastloom-0.3.3/fastloom/tenant/__init__.py +3 -0
  56. fastloom-0.3.3/fastloom/tenant/base/__init__.py +0 -0
  57. fastloom-0.3.3/fastloom/tenant/base/utils.py +8 -0
  58. fastloom-0.3.3/fastloom/tenant/depends.py +223 -0
  59. fastloom-0.3.3/fastloom/tenant/handler.py +63 -0
  60. fastloom-0.3.3/fastloom/tenant/protocols.py +15 -0
  61. fastloom-0.3.3/fastloom/tenant/schemas.py +15 -0
  62. fastloom-0.3.3/fastloom/tenant/settings.py +212 -0
  63. fastloom-0.3.3/fastloom/tenant/utils.py +92 -0
  64. fastloom-0.3.3/fastloom/tests.py +50 -0
  65. fastloom-0.3.3/fastloom/types.py +57 -0
  66. fastloom-0.3.3/fastloom/utils.py +27 -0
  67. fastloom-0.3.3/pyproject.toml +98 -0
@@ -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
+
@@ -0,0 +1,6 @@
1
+ # Core bluprint Core Package
2
+ ## Install on other projects
3
+ 1. Run `poetry config http-basic.microservice-registry <your username> <your personal token>`
4
+
5
+ ## Committing updates
6
+ 1. Run `poetry version --next-phase patch`
File without changes
@@ -0,0 +1,5 @@
1
+ from contextvars import ContextVar
2
+
3
+ from fastloom.auth.schemas import UserClaims
4
+
5
+ Claims: ContextVar[UserClaims] = ContextVar("claims")
@@ -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,5 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class IntrospectionResponse(BaseModel):
5
+ active: bool
@@ -0,0 +1,12 @@
1
+ from pathlib import Path
2
+ from typing import Protocol
3
+
4
+ from pydantic import AnyHttpUrl
5
+
6
+
7
+ class OAuth2Settings(Protocol):
8
+ IAM_TOKEN_URL: AnyHttpUrl | Path
9
+
10
+
11
+ class SidecarSettings(OAuth2Settings, Protocol):
12
+ IAM_SIDECAR_URL: AnyHttpUrl
@@ -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,9 @@
1
+ from pydantic import (
2
+ BaseModel,
3
+ Field,
4
+ RedisDsn,
5
+ )
6
+
7
+
8
+ class RedisSettings(BaseModel):
9
+ redis_url: RedisDsn = Field(RedisDsn("redis://localhost:6379/0"))
@@ -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"
@@ -0,0 +1,6 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class MongoSettings(BaseModel):
5
+ MONGO_URI: str
6
+ MONGO_DATABASE: str