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.
- fastapi_mongo_base-0.2.0/LICENSE.txt +19 -0
- fastapi_mongo_base-0.2.0/PKG-INFO +66 -0
- fastapi_mongo_base-0.2.0/README.md +7 -0
- fastapi_mongo_base-0.2.0/pyproject.toml +47 -0
- fastapi_mongo_base-0.2.0/setup.cfg +4 -0
- fastapi_mongo_base-0.2.0/src/fastapi-mongo-base/__init__.py +0 -0
- fastapi_mongo_base-0.2.0/src/fastapi-mongo-base/handlers.py +80 -0
- fastapi_mongo_base-0.2.0/src/fastapi-mongo-base/models.py +275 -0
- fastapi_mongo_base-0.2.0/src/fastapi-mongo-base/routes.py +239 -0
- fastapi_mongo_base-0.2.0/src/fastapi-mongo-base/schemas.py +90 -0
- fastapi_mongo_base-0.2.0/src/fastapi-mongo-base/tasks.py +197 -0
- fastapi_mongo_base-0.2.0/src/fastapi_mongo_base.egg-info/PKG-INFO +66 -0
- fastapi_mongo_base-0.2.0/src/fastapi_mongo_base.egg-info/SOURCES.txt +17 -0
- fastapi_mongo_base-0.2.0/src/fastapi_mongo_base.egg-info/dependency_links.txt +1 -0
- fastapi_mongo_base-0.2.0/src/fastapi_mongo_base.egg-info/requires.txt +18 -0
- fastapi_mongo_base-0.2.0/src/fastapi_mongo_base.egg-info/top_level.txt +1 -0
- fastapi_mongo_base-0.2.0/tests/test_api.py +61 -0
- fastapi_mongo_base-0.2.0/tests/test_core.py +81 -0
- fastapi_mongo_base-0.2.0/tests/test_simple.py +17 -0
|
@@ -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,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"]}
|
|
File without changes
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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()
|