fastapi-mongo-base 0.2.0__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.
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2016 The Python Packaging Authority (PyPA)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7
+ of the Software, and to permit persons to whom the Software is furnished to do
8
+ so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.1
2
+ Name: fastapi-mongo-base
3
+ Version: 0.2.0
4
+ Summary: A simple boilerplate application, including models and schemas and abstract router, for FastAPI with MongoDB
5
+ Author-email: Mahdi Kiani <mahdikiany@gmail.com>
6
+ Maintainer-email: Mahdi Kiani <mahdikiany@gmail.com>
7
+ License: Copyright (c) 2016 The Python Packaging Authority (PyPA)
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
10
+ this software and associated documentation files (the "Software"), to deal in
11
+ the Software without restriction, including without limitation the rights to
12
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
13
+ of the Software, and to permit persons to whom the Software is furnished to do
14
+ so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://github.com/mahdikiani/fastapi-mongo-base-app
28
+ Project-URL: Bug Reports, https://github.com/mahdikiani/fastapi-mongo-base-app/issues
29
+ Project-URL: Funding, https://github.com/mahdikiani/fastapi-mongo-base-app
30
+ Project-URL: Say Thanks!, https://saythanks.io/to/mahdikiani
31
+ Project-URL: Source, https://github.com/mahdikiani/fastapi-mongo-base-app
32
+ Keywords: fastapi,mongodb,beanie
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: Topic :: Software Development :: Build Tools
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3 :: Only
42
+ Requires-Python: >=3.9
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE.txt
45
+ Requires-Dist: peppercorn
46
+ Requires-Dist: pydantic>=1.8.2
47
+ Requires-Dist: requests>=2.26.0
48
+ Requires-Dist: pyjwt[crypto]
49
+ Requires-Dist: singleton_package
50
+ Provides-Extra: fastapi
51
+ Requires-Dist: fastapi>=0.65.0; extra == "fastapi"
52
+ Requires-Dist: uvicorn[standard]>=0.13.0; extra == "fastapi"
53
+ Provides-Extra: django
54
+ Requires-Dist: Django>=3.2; extra == "django"
55
+ Provides-Extra: dev
56
+ Requires-Dist: check-manifest; extra == "dev"
57
+ Provides-Extra: test
58
+ Requires-Dist: coverage; extra == "test"
59
+
60
+ # Fastapi Mongo
61
+
62
+ ## Contributing
63
+ Contributions are welcome! See CONTRIBUTING.md for more details on how to get involved.
64
+
65
+ ## License
66
+ Distributed under the MIT License. See LICENSE for more information.
@@ -0,0 +1,7 @@
1
+ # Fastapi Mongo
2
+
3
+ ## Contributing
4
+ Contributions are welcome! See CONTRIBUTING.md for more details on how to get involved.
5
+
6
+ ## License
7
+ Distributed under the MIT License. See LICENSE for more information.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "fastapi-mongo-base"
7
+ version = "0.2.0"
8
+ description = "A simple boilerplate application, including models and schemas and abstract router, for FastAPI with MongoDB"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {file = "LICENSE.txt"}
12
+ keywords = ["fastapi", "mongodb", "beanie"]
13
+ authors = [
14
+ {name = "Mahdi Kiani", email = "mahdikiany@gmail.com"}
15
+ ]
16
+ maintainers = [
17
+ {name = "Mahdi Kiani", email = "mahdikiany@gmail.com"}
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Intended Audience :: Developers",
22
+ "Topic :: Software Development :: Build Tools",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3 :: Only",
29
+ ]
30
+ dependencies = [
31
+ "peppercorn", # Example main dependency
32
+ "pydantic>=1.8.2",
33
+ "requests>=2.26.0",
34
+ "pyjwt[crypto]",
35
+ "singleton_package"
36
+ ]
37
+ optional-dependencies = {"fastapi" = ["fastapi>=0.65.0", "uvicorn[standard]>=0.13.0"],"django" = ["Django>=3.2"],"dev" = ["check-manifest"],"test" = ["coverage"]}
38
+
39
+ [project.urls]
40
+ "Homepage" = "https://github.com/mahdikiani/fastapi-mongo-base-app"
41
+ "Bug Reports" = "https://github.com/mahdikiani/fastapi-mongo-base-app/issues"
42
+ "Funding" = "https://github.com/mahdikiani/fastapi-mongo-base-app"
43
+ "Say Thanks!" = "https://saythanks.io/to/mahdikiani"
44
+ "Source" = "https://github.com/mahdikiani/fastapi-mongo-base-app"
45
+
46
+ [tool.setuptools]
47
+ package-data = {"usso" = ["*.dat"]}
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,80 @@
1
+ import uuid
2
+ from typing import TypeVar
3
+
4
+ from fastapi import Request
5
+
6
+ from .schemas import BaseEntitySchema, OwnedEntitySchema
7
+
8
+ T = TypeVar("T", bound=BaseEntitySchema)
9
+ OT = TypeVar("OT", bound=OwnedEntitySchema)
10
+
11
+
12
+ def create_dto(cls: OT):
13
+ async def dto(
14
+ request: Request,
15
+ *,
16
+ user_id: uuid.UUID = None,
17
+ business_name: str = None,
18
+ **kwargs
19
+ ):
20
+ form_data = await request.json()
21
+
22
+ if hasattr(cls, "create_field_set") and cls.create_field_set():
23
+ for key in form_data.keys():
24
+ if key not in cls.create_field_set():
25
+ form_data.pop(key, None)
26
+
27
+ if hasattr(cls, "create_exclude_set") and cls.create_exclude_set():
28
+ for key in cls.create_exclude_set():
29
+ form_data.pop(key, None)
30
+
31
+ if user_id:
32
+ form_data["user_id"] = user_id
33
+
34
+ if business_name:
35
+ form_data["business_name"] = business_name
36
+
37
+ return cls(**form_data)
38
+
39
+ return dto
40
+
41
+
42
+ # def update_dto(cls: OT):
43
+ # async def dto(request: Request, user: UserData = None, **kwargs):
44
+ # uid = request.path_params["uid"]
45
+ # form_data = await request.json()
46
+ # kwargs = {}
47
+ # if user:
48
+ # kwargs["user"] = user
49
+ # item = await cls.get_item(uid, **kwargs)
50
+
51
+ # if not item:
52
+ # raise BaseHTTPException(
53
+ # status_code=404,
54
+ # error="item_not_found",
55
+ # message="Item not found",
56
+ # )
57
+
58
+ # item_data = item.model_dump() | form_data
59
+
60
+ # return cls(**item_data)
61
+
62
+ # return dto
63
+
64
+
65
+ # def update_dto(cls: Type[OT]) -> Callable:
66
+ # async def dto(
67
+ # request: Request, item: OT, user: Optional[UserData] = None, **kwargs
68
+ # ) -> OT:
69
+ # # request.path_params["uid"]
70
+ # form_data = await request.json()
71
+ # # kwargs = {}
72
+ # # if user:
73
+ # # kwargs["user_id"] = user.uid
74
+
75
+ # for key, value in form_data.items():
76
+ # setattr(item, key, value)
77
+
78
+ # return item
79
+
80
+ # return dto
@@ -0,0 +1,275 @@
1
+ import uuid
2
+ from datetime import datetime
3
+
4
+ from .schemas import (
5
+ BaseEntitySchema,
6
+ BusinessEntitySchema,
7
+ BusinessOwnedEntitySchema,
8
+ OwnedEntitySchema,
9
+ )
10
+ from beanie import Document, Insert, Replace, Save, SaveChanges, Update, before_event
11
+ from pydantic import ConfigDict
12
+ from pymongo import ASCENDING, IndexModel
13
+ from server.config import Settings
14
+
15
+ from .tasks import TaskMixin
16
+
17
+
18
+ class BaseEntity(BaseEntitySchema, Document):
19
+ class Settings:
20
+ __abstract__ = True
21
+
22
+ keep_nulls = False
23
+ validate_on_save = True
24
+
25
+ indexes = [
26
+ IndexModel([("uid", ASCENDING)], unique=True),
27
+ ]
28
+
29
+ @before_event([Insert, Replace, Save, SaveChanges, Update])
30
+ async def pre_save(self):
31
+ self.updated_at = datetime.now()
32
+
33
+ @classmethod
34
+ def get_query(
35
+ cls,
36
+ user_id: uuid.UUID = None,
37
+ business_name: str = None,
38
+ is_deleted: bool = False,
39
+ *args,
40
+ **kwargs,
41
+ ):
42
+ base_query = [cls.is_deleted == is_deleted]
43
+ if hasattr(cls, "user_id") and user_id:
44
+ base_query.append(cls.user_id == user_id)
45
+ if hasattr(cls, "business_name"):
46
+ base_query.append(cls.business_name == business_name)
47
+
48
+ query = cls.find(*base_query)
49
+ return query
50
+
51
+ @classmethod
52
+ async def get_item(
53
+ cls,
54
+ uid,
55
+ user_id: uuid.UUID = None,
56
+ business_name: str = None,
57
+ is_deleted: bool = False,
58
+ *args,
59
+ **kwargs,
60
+ ) -> "BaseEntity":
61
+ query = cls.get_query(
62
+ user_id=user_id,
63
+ business_name=business_name,
64
+ is_deleted=is_deleted,
65
+ *args,
66
+ **kwargs,
67
+ ).find(cls.uid == uid)
68
+ items = await query.to_list()
69
+ if not items:
70
+ return None
71
+ if len(items) > 1:
72
+ raise ValueError("Multiple items found")
73
+ return items[0]
74
+
75
+ @classmethod
76
+ def adjust_pagination(cls, offset: int, limit: int):
77
+ offset = max(offset or 0, 0)
78
+ limit = max(1, min(limit or 10, Settings.page_max_limit))
79
+ return offset, limit
80
+
81
+ @classmethod
82
+ async def list_items(
83
+ cls,
84
+ user_id: uuid.UUID = None,
85
+ business_name: str = None,
86
+ offset: int = 0,
87
+ limit: int = 10,
88
+ is_deleted: bool = False,
89
+ *args,
90
+ **kwargs,
91
+ ):
92
+ offset, limit = cls.adjust_pagination(offset, limit)
93
+
94
+ query = cls.get_query(
95
+ user_id=user_id,
96
+ business_name=business_name,
97
+ is_deleted=is_deleted,
98
+ *args,
99
+ **kwargs,
100
+ )
101
+
102
+ items_query = query.sort("-created_at").skip(offset).limit(limit)
103
+ items = await items_query.to_list()
104
+ return items
105
+
106
+ @classmethod
107
+ async def total_count(
108
+ cls,
109
+ user_id: uuid.UUID = None,
110
+ business_name: str = None,
111
+ is_deleted: bool = False,
112
+ *args,
113
+ **kwargs,
114
+ ):
115
+ query = cls.get_query(
116
+ user_id=user_id,
117
+ business_name=business_name,
118
+ is_deleted=is_deleted,
119
+ *args,
120
+ **kwargs,
121
+ )
122
+ return await query.count()
123
+
124
+ @classmethod
125
+ async def list_total_combined(
126
+ cls,
127
+ user_id: uuid.UUID = None,
128
+ business_name: str = None,
129
+ offset: int = 0,
130
+ limit: int = 10,
131
+ is_deleted: bool = False,
132
+ *args,
133
+ **kwargs,
134
+ ) -> tuple[list["BaseEntity"], int]:
135
+ offset, limit = cls.adjust_pagination(offset, limit)
136
+
137
+ query = cls.get_query(
138
+ user_id=user_id,
139
+ business_name=business_name,
140
+ is_deleted=is_deleted,
141
+ *args,
142
+ **kwargs,
143
+ )
144
+ items_query = query.sort("-created_at").skip(offset).limit(limit)
145
+ items = await items_query.to_list()
146
+ total = await query.count()
147
+
148
+ return items, total
149
+
150
+ @classmethod
151
+ async def create_item(cls, data: dict):
152
+ # for key in data.keys():
153
+ # if cls.create_exclude_set() and key not in cls.create_field_set():
154
+ # data.pop(key, None)
155
+ # elif cls.create_exclude_set() and key in cls.create_exclude_set():
156
+ # data.pop(key, None)
157
+
158
+ item = cls(**data)
159
+ await item.save()
160
+ return item
161
+
162
+ @classmethod
163
+ async def update_item(cls, item: "BaseEntity", data: dict):
164
+ for key, value in data.items():
165
+ if cls.update_field_set() and key not in cls.update_field_set():
166
+ continue
167
+ if cls.update_exclude_set() and key in cls.update_exclude_set():
168
+ continue
169
+
170
+ setattr(item, key, value)
171
+
172
+ await item.save()
173
+ return item
174
+
175
+ @classmethod
176
+ async def delete_item(cls, item: "BaseEntity"):
177
+ item.is_deleted = True
178
+ await item.save()
179
+ return item
180
+
181
+
182
+ class OwnedEntity(OwnedEntitySchema, BaseEntity):
183
+
184
+ class Settings(BaseEntity.Settings):
185
+ __abstract__ = True
186
+
187
+ indexes = BaseEntity.Settings.indexes + [IndexModel([("user_id", ASCENDING)])]
188
+
189
+ @classmethod
190
+ async def get_item(cls, uid, user_id, *args, **kwargs) -> "OwnedEntity":
191
+ if user_id == None:
192
+ raise ValueError("user_id is required")
193
+ return await super().get_item(uid, user_id=user_id, *args, **kwargs)
194
+
195
+
196
+ class BusinessEntity(BusinessEntitySchema, BaseEntity):
197
+
198
+ class Settings(BaseEntity.Settings):
199
+ __abstract__ = True
200
+
201
+ indexes = BaseEntity.Settings.indexes + [
202
+ IndexModel([("business_name", ASCENDING)])
203
+ ]
204
+
205
+ @classmethod
206
+ async def get_item(cls, uid, business_name, *args, **kwargs) -> "BusinessEntity":
207
+ if business_name == None:
208
+ raise ValueError("business_name is required")
209
+ return await super().get_item(uid, business_name=business_name, *args, **kwargs)
210
+
211
+ async def get_business(self):
212
+ raise NotImplementedError
213
+ from apps.business_mongo.models import Business
214
+
215
+ return await Business.get_by_name(self.business_name)
216
+
217
+
218
+ class BusinessOwnedEntity(BusinessOwnedEntitySchema, BaseEntity):
219
+
220
+ class Settings(BusinessEntity.Settings):
221
+ __abstract__ = True
222
+
223
+ indexes = BusinessEntity.Settings.indexes + [
224
+ IndexModel([("user_id", ASCENDING)])
225
+ ]
226
+
227
+ @classmethod
228
+ async def get_item(
229
+ cls, uid, business_name, user_id, *args, **kwargs
230
+ ) -> "BusinessOwnedEntity":
231
+ if business_name == None:
232
+ raise ValueError("business_name is required")
233
+ # if user_id == None:
234
+ # raise ValueError("user_id is required")
235
+ return await super().get_item(
236
+ uid, business_name=business_name, user_id=user_id, *args, **kwargs
237
+ )
238
+
239
+
240
+ class BaseEntityTaskMixin(BaseEntity, TaskMixin):
241
+ class Settings(BaseEntity.Settings):
242
+ __abstract__ = True
243
+
244
+
245
+ class ImmutableBase(BaseEntity):
246
+ model_config = ConfigDict(frozen=True)
247
+
248
+ class Settings(BaseEntity.Settings):
249
+ __abstract__ = True
250
+
251
+ @classmethod
252
+ async def update_item(cls, item: "BaseEntity", data: dict):
253
+ raise ValueError("Immutable items cannot be updated")
254
+
255
+ @classmethod
256
+ async def delete_item(cls, item: "BaseEntity"):
257
+ raise ValueError("Immutable items cannot be deleted")
258
+
259
+
260
+ class ImmutableOwnedEntity(ImmutableBase, OwnedEntity):
261
+
262
+ class Settings(OwnedEntity.Settings):
263
+ __abstract__ = True
264
+
265
+
266
+ class ImmutableBusinessEntity(ImmutableBase, BusinessEntity):
267
+
268
+ class Settings(BusinessEntity.Settings):
269
+ __abstract__ = True
270
+
271
+
272
+ class ImmutableBusinessOwnedEntity(ImmutableBase, BusinessOwnedEntity):
273
+
274
+ class Settings(BusinessOwnedEntity.Settings):
275
+ __abstract__ = True
@@ -0,0 +1,239 @@
1
+ import uuid
2
+ from typing import Any, Generic, Type, TypeVar
3
+
4
+ import singleton
5
+ from .handlers import create_dto
6
+ from .schemas import BaseEntitySchema, PaginatedResponse
7
+ from core.exceptions import BaseHTTPException
8
+ from fastapi import APIRouter, BackgroundTasks, Query, Request
9
+ from server.config import Settings
10
+
11
+ from .models import BaseEntity, BaseEntityTaskMixin
12
+
13
+ # Define a type variable
14
+ T = TypeVar("T", bound=BaseEntity)
15
+ TE = TypeVar("TE", bound=BaseEntityTaskMixin)
16
+ TS = TypeVar("TS", bound=BaseEntitySchema)
17
+
18
+
19
+ class AbstractBaseRouter(Generic[T, TS], metaclass=singleton.Singleton):
20
+
21
+ def __init__(
22
+ self,
23
+ model: Type[T],
24
+ user_dependency: Any,
25
+ *args,
26
+ prefix: str = None,
27
+ tags: list[str] = None,
28
+ schema: Type[TS] = None,
29
+ **kwargs,
30
+ ):
31
+ self.model = model
32
+ if schema is None:
33
+ schema = self.model
34
+ self.schema = schema
35
+ self.user_dependency = user_dependency
36
+ if prefix is None:
37
+ prefix = f"/{self.model.__name__.lower()}s"
38
+ if tags is None:
39
+ tags = [self.model.__name__]
40
+ self.router = APIRouter(prefix=prefix, tags=tags, **kwargs)
41
+
42
+ self.config_schemas(self.schema, **kwargs)
43
+ self.config_routes(**kwargs)
44
+
45
+ @classmethod
46
+ def config_schemas(cls, schema, **kwargs):
47
+ cls.list_response_schema = PaginatedResponse[schema]
48
+ cls.list_item_schema = schema
49
+ cls.retrieve_response_schema = schema
50
+ cls.create_response_schema = schema
51
+ cls.update_response_schema = schema
52
+ cls.delete_response_schema = schema
53
+
54
+ cls.create_request_schema = schema
55
+ cls.update_request_schema = schema
56
+
57
+ def config_routes(self, **kwargs):
58
+ self.router.add_api_route(
59
+ "/",
60
+ self.list_items,
61
+ methods=["GET"],
62
+ response_model=self.list_response_schema,
63
+ status_code=200,
64
+ )
65
+ self.router.add_api_route(
66
+ "/{uid:uuid}",
67
+ self.retrieve_item,
68
+ methods=["GET"],
69
+ response_model=self.retrieve_response_schema,
70
+ status_code=200,
71
+ )
72
+ self.router.add_api_route(
73
+ "/",
74
+ self.create_item,
75
+ methods=["POST"],
76
+ response_model=self.create_response_schema,
77
+ status_code=201,
78
+ )
79
+ self.router.add_api_route(
80
+ "/{uid:uuid}",
81
+ self.update_item,
82
+ methods=["PATCH"],
83
+ response_model=self.update_response_schema,
84
+ status_code=200,
85
+ )
86
+ self.router.add_api_route(
87
+ "/{uid:uuid}",
88
+ self.delete_item,
89
+ methods=["DELETE"],
90
+ response_model=self.delete_response_schema,
91
+ # status_code=204,
92
+ )
93
+
94
+ async def get_item(
95
+ self,
96
+ uid: uuid.UUID,
97
+ user_id: uuid.UUID = None,
98
+ business_name: str = None,
99
+ **kwargs,
100
+ ):
101
+ item = await self.model.get_item(
102
+ uid, user_id=user_id, business_name=business_name, **kwargs
103
+ )
104
+ if item is None:
105
+ raise BaseHTTPException(
106
+ status_code=404,
107
+ error="item_not_found",
108
+ message=f"{self.model.__name__.capitalize()} not found",
109
+ )
110
+ return item
111
+
112
+ async def get_user(self, request: Request, *args, **kwargs):
113
+ if self.user_dependency is None:
114
+ return None
115
+ return await self.user_dependency(request)
116
+
117
+ async def get_user_id(self, request: Request, *args, **kwargs):
118
+ user = await self.get_user(request)
119
+ user_id = user.uid if user else None
120
+ return user_id
121
+
122
+ async def list_items(
123
+ self,
124
+ request: Request,
125
+ offset: int = Query(0, ge=0),
126
+ limit: int = Query(10, ge=1, le=Settings.page_max_limit),
127
+ ):
128
+ user_id = await self.get_user_id(request)
129
+ limit = max(1, min(limit, Settings.page_max_limit))
130
+
131
+ items, total = await self.model.list_total_combined(
132
+ user_id=user_id, offset=offset, limit=limit
133
+ )
134
+ items_in_schema = [self.list_item_schema(**item.model_dump()) for item in items]
135
+
136
+ return PaginatedResponse(
137
+ items=items_in_schema,
138
+ total=total,
139
+ offset=offset,
140
+ limit=limit,
141
+ )
142
+
143
+ async def retrieve_item(
144
+ self,
145
+ request: Request,
146
+ uid: uuid.UUID,
147
+ ):
148
+ user_id = await self.get_user_id(request)
149
+ item = await self.get_item(uid, user_id=user_id)
150
+ return item
151
+
152
+ async def create_item(
153
+ self,
154
+ request: Request,
155
+ data: dict,
156
+ ):
157
+ user_id = await self.get_user_id(request)
158
+ item_data: TS = await create_dto(self.create_response_schema)(
159
+ request, user_id=user_id
160
+ )
161
+ item = await self.model.create_item(item_data.model_dump())
162
+ # item: T = await create_dto(self.create_request_schema)(request, user)
163
+ await item.save()
164
+ return item
165
+
166
+ async def update_item(
167
+ self,
168
+ request: Request,
169
+ uid: uuid.UUID,
170
+ data: dict,
171
+ ):
172
+ user_id = await self.get_user_id(request)
173
+ item = await self.get_item(uid, user_id=user_id)
174
+ # item = await update_dto(self.model)(request, user)
175
+ item = await self.model.update_item(item, data)
176
+ return item
177
+
178
+ async def delete_item(
179
+ self,
180
+ request: Request,
181
+ uid: uuid.UUID,
182
+ ):
183
+ user_id = await self.get_user_id(request)
184
+ item = await self.get_item(uid, user_id=user_id)
185
+
186
+ item = await self.model.delete_item(item)
187
+ return item
188
+
189
+
190
+ class AbstractTaskRouter(AbstractBaseRouter[TE, TS]):
191
+ def __init__(
192
+ self, model: Type[TE], user_dependency: Any, schema: TS, *args, **kwargs
193
+ ):
194
+ super().__init__(model, user_dependency, schema=schema, *args, **kwargs)
195
+ self.router.add_api_route(
196
+ "/{uid:uuid}/start",
197
+ self.start_item,
198
+ methods=["POST"],
199
+ response_model=self.retrieve_response_schema,
200
+ )
201
+
202
+ async def start_item(
203
+ self, request: Request, uid: uuid.UUID, background_tasks: BackgroundTasks
204
+ ):
205
+ user_id = await self.get_user_id(request)
206
+ item: TE = await self.get_item(uid, user_id=user_id)
207
+ background_tasks.add_task(item.start_processing)
208
+ return item.model_dump()
209
+
210
+
211
+ def copy_router(router: APIRouter, new_prefix: str):
212
+ new_router = APIRouter(prefix=new_prefix)
213
+ for route in router.routes:
214
+ new_router.add_api_route(
215
+ route.path.replace(router.prefix, ""),
216
+ route.endpoint,
217
+ methods=[
218
+ method
219
+ for method in route.methods
220
+ if method in ["GET", "POST", "PUT", "DELETE", "PATCH"]
221
+ ],
222
+ name=route.name,
223
+ response_class=route.response_class,
224
+ status_code=route.status_code,
225
+ tags=route.tags,
226
+ dependencies=route.dependencies,
227
+ summary=route.summary,
228
+ description=route.description,
229
+ response_description=route.response_description,
230
+ responses=route.responses,
231
+ deprecated=route.deprecated,
232
+ include_in_schema=route.include_in_schema,
233
+ response_model=route.response_model,
234
+ response_model_include=route.response_model_include,
235
+ response_model_exclude=route.response_model_exclude,
236
+ response_model_by_alias=route.response_model_by_alias,
237
+ )
238
+
239
+ return new_router
@@ -0,0 +1,90 @@
1
+ import uuid
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from typing import Any, Generic, TypeVar
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class CoreEntitySchema(BaseModel):
10
+ created_at: datetime = Field(
11
+ default_factory=datetime.now, json_schema_extra={"index": True}
12
+ )
13
+ updated_at: datetime = Field(default_factory=datetime.now)
14
+ is_deleted: bool = False
15
+ meta_data: dict[str, Any] | None = None
16
+
17
+
18
+ class BaseEntitySchema(CoreEntitySchema):
19
+ uid: uuid.UUID = Field(
20
+ default_factory=uuid.uuid4, json_schema_extra={"index": True, "unique": True}
21
+ )
22
+
23
+ @classmethod
24
+ def create_exclude_set(cls) -> list[str]:
25
+ return ["uid", "created_at", "updated_at", "is_deleted"]
26
+
27
+ @classmethod
28
+ def create_field_set(cls) -> list:
29
+ return []
30
+
31
+ @classmethod
32
+ def update_exclude_set(cls) -> list:
33
+ return ["uid", "created_at", "updated_at"]
34
+
35
+ @classmethod
36
+ def update_field_set(cls) -> list:
37
+ return []
38
+
39
+ def expired(self, days: int = 3):
40
+ return (datetime.now() - self.updated_at).days > days
41
+
42
+
43
+ class OwnedEntitySchema(BaseEntitySchema):
44
+ user_id: uuid.UUID
45
+
46
+ @classmethod
47
+ def create_exclude_set(cls) -> list[str]:
48
+ return super().create_exclude_set() + ["user_id"]
49
+
50
+ @classmethod
51
+ def update_exclude_set(cls) -> list[str]:
52
+ return super().update_exclude_set() + ["user_id"]
53
+
54
+
55
+ class BusinessEntitySchema(BaseEntitySchema):
56
+ business_name: str
57
+
58
+ @classmethod
59
+ def create_exclude_set(cls) -> list[str]:
60
+ return super().create_exclude_set() + ["business_name"]
61
+
62
+ @classmethod
63
+ def update_exclude_set(cls) -> list[str]:
64
+ return super().update_exclude_set() + ["business_name"]
65
+
66
+
67
+ class BusinessOwnedEntitySchema(OwnedEntitySchema, BusinessEntitySchema):
68
+
69
+ @classmethod
70
+ def create_exclude_set(cls) -> list[str]:
71
+ return list(set(super().create_exclude_set() + ["business_name", "user_id"]))
72
+
73
+ @classmethod
74
+ def update_exclude_set(cls) -> list[str]:
75
+ return list(set(super().update_exclude_set() + ["business_name", "user_id"]))
76
+
77
+
78
+ class Language(str, Enum):
79
+ English = "English"
80
+ Persian = "Persian"
81
+
82
+
83
+ T = TypeVar("T", bound=BaseEntitySchema)
84
+
85
+
86
+ class PaginatedResponse(BaseModel, Generic[T]):
87
+ items: list[T]
88
+ total: int
89
+ offset: int
90
+ limit: int
@@ -0,0 +1,197 @@
1
+ import asyncio
2
+ import logging
3
+ import uuid
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ from typing import Any, Callable, Coroutine, Literal, Union
7
+
8
+ from .schemas import BaseEntitySchema
9
+ from pydantic import BaseModel, Field
10
+ from singleton import Singleton
11
+ from utils import aionetwork, basic
12
+
13
+
14
+ class SignalRegistry(metaclass=Singleton):
15
+ def __init__(self):
16
+ self.signal_map: dict[
17
+ str,
18
+ list[Callable[..., None] | Callable[..., Coroutine[Any, Any, None]]],
19
+ ] = {}
20
+
21
+
22
+ class TaskLogRecord(BaseModel):
23
+ reported_at: datetime = Field(default_factory=datetime.now)
24
+ message: str
25
+ task_status: Literal["draft", "init", "processing", "completed", "error"]
26
+ duration: int = 0
27
+ data: dict | None = None
28
+
29
+ def __eq__(self, other):
30
+ if isinstance(other, TaskLogRecord):
31
+ return (
32
+ self.reported_at == other.reported_at
33
+ and self.message == other.message
34
+ and self.task_status == other.task_status
35
+ and self.duration == other.duration
36
+ and self.data == other.data
37
+ )
38
+ return False
39
+
40
+ def __hash__(self):
41
+ return hash((self.reported_at, self.message, self.task_status, self.duration))
42
+
43
+
44
+ class TaskReference(BaseModel):
45
+ task_id: uuid.UUID
46
+ task_type: str
47
+
48
+ def __eq__(self, other):
49
+ if isinstance(other, TaskReference):
50
+ return self.task_id == other.task_id and self.task_type == other.task_type
51
+ return False
52
+
53
+ def __hash__(self):
54
+ return hash((self.task_id, self.task_type))
55
+
56
+ async def get_task_item(self) -> BaseEntitySchema | None:
57
+ task_classes = {
58
+ subclass.__name__: subclass
59
+ for subclass in basic.get_all_subclasses(TaskMixin)
60
+ if issubclass(subclass, BaseEntitySchema)
61
+ }
62
+ # task_classes = self._get_all_task_classes()
63
+
64
+ task_class = task_classes.get(self.task_type)
65
+ if not task_class:
66
+ raise ValueError(f"Task type {self.task_type} is not supported.")
67
+
68
+ task_item = await task_class.find_one(task_class.uid == self.task_id)
69
+ if not task_item:
70
+ raise ValueError(
71
+ f"No task found with id {self.task_id} of type {self.task_type}."
72
+ )
73
+
74
+ return task_item
75
+
76
+
77
+ class TaskReferenceList(BaseModel):
78
+ tasks: list[Union[TaskReference, "TaskReferenceList"]] = []
79
+ mode: Literal["serial", "parallel"] = "serial"
80
+
81
+ async def list_processing(self):
82
+ task_items = [task.get_task_item() for task in self.tasks]
83
+ match self.mode:
84
+ case "serial":
85
+ for task_item in task_items:
86
+ await task_item.start_processing()
87
+ case "parallel":
88
+ await asyncio.gather(*[task.start_processing() for task in task_items])
89
+
90
+
91
+ class TaskStatusEnum(str, Enum):
92
+ draft = "draft"
93
+ init = "init"
94
+ processing = "processing"
95
+ completed = "completed"
96
+ error = "error"
97
+
98
+
99
+ class TaskMixin(BaseModel):
100
+ task_status: Literal["draft", "init", "processing", "completed", "error"] = "draft"
101
+ task_report: str | None = None
102
+ task_progress: int = -1
103
+ task_logs: list[TaskLogRecord] = []
104
+ task_references: TaskReferenceList | None = None
105
+
106
+ @classmethod
107
+ def signals(cls):
108
+ registry = SignalRegistry()
109
+ if cls.__name__ not in registry.signal_map:
110
+ registry.signal_map[cls.__name__] = []
111
+ return registry.signal_map[cls.__name__]
112
+
113
+ @classmethod
114
+ def add_signal(
115
+ cls,
116
+ signal: Callable[..., None] | Callable[..., Coroutine[Any, Any, None]],
117
+ ):
118
+ cls.signals().append(signal)
119
+
120
+ @classmethod
121
+ async def emit_signals(cls, task_instance, **kwargs):
122
+ if task_instance.meta_data:
123
+ webhook = task_instance.meta_data.get(
124
+ "webhook"
125
+ ) or task_instance.meta_data.get("webhook_url")
126
+ webhook_signals = [
127
+ aionetwork.aio_request(
128
+ method="post",
129
+ url=webhook,
130
+ data=task_instance.model_dump_json(),
131
+ )
132
+ ]
133
+ else:
134
+ webhook_signals = []
135
+
136
+ signals = webhook_signals + [
137
+ (
138
+ signal(task_instance)
139
+ if asyncio.iscoroutinefunction(signal)
140
+ else asyncio.to_thread(signal, task_instance)
141
+ )
142
+ for signal in cls.signals()
143
+ ]
144
+
145
+ await asyncio.gather(*signals)
146
+
147
+ async def save_status(
148
+ self,
149
+ status: Literal["draft", "init", "processing", "done", "error"],
150
+ **kwargs,
151
+ ):
152
+ self.task_status = status
153
+ await self.add_log(
154
+ TaskLogRecord(
155
+ task_status=self.task_status,
156
+ message=f"Status changed to {status}",
157
+ ),
158
+ **kwargs,
159
+ )
160
+
161
+ async def add_reference(self, task_id: uuid.UUID, **kwargs):
162
+ self.task_references.append(task_id)
163
+ await self.add_log(
164
+ TaskLogRecord(
165
+ task_status=self.task_status,
166
+ message=f"Added reference to task {task_id}",
167
+ ),
168
+ **kwargs,
169
+ )
170
+
171
+ async def save_report(self, report: str, **kwargs):
172
+ self.task_report = report
173
+ await self.add_log(
174
+ TaskLogRecord(
175
+ task_status=self.task_status,
176
+ message=report,
177
+ ),
178
+ **kwargs,
179
+ )
180
+
181
+ async def add_log(self, log_record: TaskLogRecord, *, emit: bool = True, **kwargs):
182
+ self.task_logs.append(log_record)
183
+ if emit:
184
+ # await self.emit_signals(self)
185
+ await self.save_and_emit()
186
+
187
+ async def start_processing(self):
188
+ if self.task_references is None:
189
+ raise NotImplementedError("Subclasses should implement this method")
190
+
191
+ await self.task_references.list_processing()
192
+
193
+ async def save_and_emit(self):
194
+ try:
195
+ await asyncio.gather(self.save(), self.emit_signals(self))
196
+ except Exception as e:
197
+ logging.error(f"An error occurred: {e}")
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.1
2
+ Name: fastapi-mongo-base
3
+ Version: 0.2.0
4
+ Summary: A simple boilerplate application, including models and schemas and abstract router, for FastAPI with MongoDB
5
+ Author-email: Mahdi Kiani <mahdikiany@gmail.com>
6
+ Maintainer-email: Mahdi Kiani <mahdikiany@gmail.com>
7
+ License: Copyright (c) 2016 The Python Packaging Authority (PyPA)
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
10
+ this software and associated documentation files (the "Software"), to deal in
11
+ the Software without restriction, including without limitation the rights to
12
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
13
+ of the Software, and to permit persons to whom the Software is furnished to do
14
+ so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://github.com/mahdikiani/fastapi-mongo-base-app
28
+ Project-URL: Bug Reports, https://github.com/mahdikiani/fastapi-mongo-base-app/issues
29
+ Project-URL: Funding, https://github.com/mahdikiani/fastapi-mongo-base-app
30
+ Project-URL: Say Thanks!, https://saythanks.io/to/mahdikiani
31
+ Project-URL: Source, https://github.com/mahdikiani/fastapi-mongo-base-app
32
+ Keywords: fastapi,mongodb,beanie
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: Topic :: Software Development :: Build Tools
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3 :: Only
42
+ Requires-Python: >=3.9
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE.txt
45
+ Requires-Dist: peppercorn
46
+ Requires-Dist: pydantic>=1.8.2
47
+ Requires-Dist: requests>=2.26.0
48
+ Requires-Dist: pyjwt[crypto]
49
+ Requires-Dist: singleton_package
50
+ Provides-Extra: fastapi
51
+ Requires-Dist: fastapi>=0.65.0; extra == "fastapi"
52
+ Requires-Dist: uvicorn[standard]>=0.13.0; extra == "fastapi"
53
+ Provides-Extra: django
54
+ Requires-Dist: Django>=3.2; extra == "django"
55
+ Provides-Extra: dev
56
+ Requires-Dist: check-manifest; extra == "dev"
57
+ Provides-Extra: test
58
+ Requires-Dist: coverage; extra == "test"
59
+
60
+ # Fastapi Mongo
61
+
62
+ ## Contributing
63
+ Contributions are welcome! See CONTRIBUTING.md for more details on how to get involved.
64
+
65
+ ## License
66
+ Distributed under the MIT License. See LICENSE for more information.
@@ -0,0 +1,17 @@
1
+ LICENSE.txt
2
+ README.md
3
+ pyproject.toml
4
+ src/fastapi-mongo-base/__init__.py
5
+ src/fastapi-mongo-base/handlers.py
6
+ src/fastapi-mongo-base/models.py
7
+ src/fastapi-mongo-base/routes.py
8
+ src/fastapi-mongo-base/schemas.py
9
+ src/fastapi-mongo-base/tasks.py
10
+ src/fastapi_mongo_base.egg-info/PKG-INFO
11
+ src/fastapi_mongo_base.egg-info/SOURCES.txt
12
+ src/fastapi_mongo_base.egg-info/dependency_links.txt
13
+ src/fastapi_mongo_base.egg-info/requires.txt
14
+ src/fastapi_mongo_base.egg-info/top_level.txt
15
+ tests/test_api.py
16
+ tests/test_core.py
17
+ tests/test_simple.py
@@ -0,0 +1,18 @@
1
+ peppercorn
2
+ pydantic>=1.8.2
3
+ requests>=2.26.0
4
+ pyjwt[crypto]
5
+ singleton_package
6
+
7
+ [dev]
8
+ check-manifest
9
+
10
+ [django]
11
+ Django>=3.2
12
+
13
+ [fastapi]
14
+ fastapi>=0.65.0
15
+ uvicorn[standard]>=0.13.0
16
+
17
+ [test]
18
+ coverage
@@ -0,0 +1 @@
1
+ fastapi-mongo-base
@@ -0,0 +1,61 @@
1
+ import os
2
+ import unittest
3
+
4
+ from usso.api import UssoAPI
5
+ from usso.core import UserData
6
+
7
+
8
+ class TestAPI(unittest.TestCase):
9
+ def get_usso(self):
10
+ return UssoAPI(
11
+ url="https://sso.usso.io",
12
+ api_key=os.getenv("USSO_API_KEY"),
13
+ )
14
+
15
+ def test_get_users(self):
16
+ usso_api = self.get_usso()
17
+ users = usso_api.get_users()
18
+ self.assertIsInstance(users, list)
19
+ for user in users:
20
+ self.assertIsInstance(user, UserData)
21
+
22
+ def test_get_user(self):
23
+ usso_api = self.get_usso()
24
+ users = usso_api.get_users()
25
+ if len(users) == 0:
26
+ self.skipTest("No users found")
27
+ user = users[0]
28
+ usso_api = self.get_usso()
29
+ user = usso_api.get_user(user.user_id)
30
+ self.assertIsInstance(user, UserData)
31
+
32
+ def test_get_user_by_credentials(self):
33
+ usso_api = self.get_usso()
34
+ users = usso_api._request(endpoint="website/users")
35
+ if len(users) == 0:
36
+ self.skipTest("No users found")
37
+ for user in users:
38
+ for auth in user["authenticators"]:
39
+ cred = {
40
+ "auth_method": auth["auth_method"],
41
+ "representor": auth["representor"],
42
+ }
43
+ user = usso_api.get_user_by_credentials(cred)
44
+ self.assertIsInstance(user, UserData)
45
+
46
+ def test_create_user_by_credentials(self):
47
+ import requests
48
+
49
+ usso_api = self.get_usso()
50
+ telegram_id = os.getenv("TELEGRAM_ID")
51
+ cred = {"auth_method": "telegram", "representor": telegram_id}
52
+ try:
53
+ usso_api.create_user_by_credentials(credentials=cred)
54
+ except requests.HTTPError as e:
55
+ if e.response.status_code == 400:
56
+ if e.response.json().get("error") == "already_exists":
57
+ self.skipTest("Credential already exists")
58
+
59
+
60
+ if __name__ == "__main__":
61
+ unittest.main()
@@ -0,0 +1,81 @@
1
+ import unittest
2
+ import uuid
3
+
4
+ from usso.core import Usso
5
+ from usso.exceptions import USSOException
6
+
7
+
8
+ def generate_expired_token():
9
+ # Your code to generate an expired token goes here
10
+ pass
11
+
12
+
13
+ # Generate an invalid token for testing
14
+ def generate_invalid_token():
15
+ # Your code to generate an invalid token goes here
16
+ pass
17
+
18
+
19
+ # Generate a valid token for testing
20
+ def generate_valid_token():
21
+ # Your code to generate a valid token goes here
22
+ pass
23
+
24
+
25
+ class TestCore(unittest.TestCase):
26
+ def test_user_data_from_token_valid_token(self):
27
+ return
28
+ # Generate a valid token for testing
29
+ valid_token = generate_valid_token()
30
+
31
+ # Call the user_data_from_token function with the valid token
32
+ user_data = Usso().user_data_from_token(valid_token)
33
+
34
+ # Assert that the user_data is not None
35
+ self.assertIsNotNone(user_data)
36
+
37
+ # Assert that the user_data has the expected attributes
38
+ self.assertEqual(user_data.uid, uuid.UUID(""))
39
+ self.assertEqual(user_data.token, valid_token)
40
+ # Add more assertions for other attributes
41
+ # Generate an expired token for testing
42
+
43
+ def test_user_data_from_token_expired_token(self):
44
+ return
45
+
46
+ # Generate an expired token for testing
47
+ expired_token = generate_expired_token()
48
+
49
+ # Call the user_data_from_token function with the expired token
50
+ user_data = Usso().user_data_from_token(expired_token)
51
+
52
+ # Assert that the user_data is None
53
+ self.assertIsNone(user_data)
54
+
55
+ # Assert that the USSOException is raised with the expected error
56
+ with self.assertRaises(USSOException) as context:
57
+ Usso().user_data_from_token(expired_token, raise_exception=True)
58
+ self.assertEqual(context.exception.error, "expired_signature")
59
+
60
+ def test_user_data_from_token_invalid_token(self):
61
+ return
62
+
63
+ # Generate an invalid token for testing
64
+ invalid_token = generate_invalid_token()
65
+
66
+ # Call the user_data_from_token function with the invalid token
67
+ user_data = Usso().user_data_from_token(invalid_token)
68
+
69
+ # Assert that the user_data is None
70
+ self.assertIsNone(user_data)
71
+
72
+ # Assert that the USSOException is raised with the expected error
73
+ with self.assertRaises(USSOException) as context:
74
+ Usso().user_data_from_token(invalid_token, raise_exception=True)
75
+ self.assertEqual(context.exception.error, "invalid_signature")
76
+
77
+ # Add more test cases for other scenarios
78
+
79
+
80
+ if __name__ == "__main__":
81
+ unittest.main()
@@ -0,0 +1,17 @@
1
+ # the inclusion of the tests module is not meant to offer best practices for
2
+ # testing in general, but rather to support the `find_packages` example in
3
+ # setup.py that excludes installing the "tests" package
4
+
5
+ import unittest
6
+
7
+
8
+ class TestSimple(unittest.TestCase):
9
+ def test_import(self):
10
+ import usso
11
+
12
+ usso.Usso()
13
+ usso.UserData(user_id="123")
14
+
15
+
16
+ if __name__ == "__main__":
17
+ unittest.main()