reachcollective-utils 0.1.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.
- reachcollective_utils-0.1.0/PKG-INFO +147 -0
- reachcollective_utils-0.1.0/README.md +132 -0
- reachcollective_utils-0.1.0/pyproject.toml +22 -0
- reachcollective_utils-0.1.0/src/reachcollective/utils/__init__.py +7 -0
- reachcollective_utils-0.1.0/src/reachcollective/utils/datagrid/datagrid.py +32 -0
- reachcollective_utils-0.1.0/src/reachcollective/utils/datagrid/filters.py +29 -0
- reachcollective_utils-0.1.0/src/reachcollective/utils/datagrid/paginator.py +45 -0
- reachcollective_utils-0.1.0/src/reachcollective/utils/datagrid/query_builder.py +67 -0
- reachcollective_utils-0.1.0/src/reachcollective/utils/datagrid/query_params.py +66 -0
- reachcollective_utils-0.1.0/src/reachcollective/utils/exception.py +18 -0
- reachcollective_utils-0.1.0/src/reachcollective/utils/render.py +161 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: reachcollective-utils
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Utilities for developing RESTful API, Open API projects with FastAPI and Python
|
|
5
|
+
Author: Juan Minaya
|
|
6
|
+
Author-email: minayaleon@gmail.com
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Requires-Dist: fastapi[standard] (>=0.115.8,<0.116.0)
|
|
12
|
+
Requires-Dist: pydantic (>=2.10.6,<3.0.0)
|
|
13
|
+
Requires-Dist: sqlmodel (>=0.0.22,<0.0.23)
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# ReachCollective / APIs / Utils
|
|
17
|
+
|
|
18
|
+
# Requirements
|
|
19
|
+
- sqlmodel (>=0.0.22,<0.0.23)
|
|
20
|
+
- fastapi (>=0.115.8,<0.116.0)
|
|
21
|
+
- pydantic (>=2.10.6,<3.0.0)
|
|
22
|
+
|
|
23
|
+
# Installation
|
|
24
|
+
|
|
25
|
+
### Poetry
|
|
26
|
+
```
|
|
27
|
+
poetry add reachcollective-utils
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
### Pip
|
|
31
|
+
```
|
|
32
|
+
pip install reachcollective-utils
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
# Examples
|
|
36
|
+
|
|
37
|
+
## DataGrid
|
|
38
|
+
List and paginate data from a model, accepting filters, sorting, and relationships
|
|
39
|
+
|
|
40
|
+
### 1. Modify models
|
|
41
|
+
Add `to_dcit()` function to each SQL Model, `to_dcit()` gets relationships.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
|
|
45
|
+
class Post(SQLModel, table=True):
|
|
46
|
+
__tablename__ = "posts"
|
|
47
|
+
|
|
48
|
+
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
49
|
+
name: str | None
|
|
50
|
+
status: str | None
|
|
51
|
+
comments: list["Comment"] | None = Relationship(back_populates="post", sa_relationship_kwargs={"lazy": "raise"})
|
|
52
|
+
|
|
53
|
+
def to_dict(self, *args, **kwargs):
|
|
54
|
+
data = super().model_dump(*args, **kwargs)
|
|
55
|
+
if 'comments' in self.__dict__:
|
|
56
|
+
data['comments'] = []
|
|
57
|
+
if self.quota_groups:
|
|
58
|
+
data['comments'] = [quota_group.to_dict(*args, **kwargs) for quota_group in self.quota_groups]
|
|
59
|
+
|
|
60
|
+
return data
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Comment(SQLModel, table=True):
|
|
64
|
+
__tablename__ = "comments"
|
|
65
|
+
|
|
66
|
+
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
67
|
+
description: str | None
|
|
68
|
+
post: Optional["Survey"] = Relationship(back_populates="comments", sa_relationship_kwargs={"lazy": "raise"})
|
|
69
|
+
|
|
70
|
+
def to_dict(self, *args, **kwargs):
|
|
71
|
+
data = super().model_dump(*args, **kwargs)
|
|
72
|
+
if 'post' in self.__dict__:
|
|
73
|
+
data['post'] = {}
|
|
74
|
+
if self.survey:
|
|
75
|
+
data['post'] = self.survey.model_dump(*args, **kwargs)
|
|
76
|
+
|
|
77
|
+
return data
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 2. Basic use
|
|
81
|
+
```python
|
|
82
|
+
# URL: /posts?sort=-created&filter[status]=active&view=paginate
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
from app.models import Post
|
|
86
|
+
from reachcollective.utils import DataGrid
|
|
87
|
+
|
|
88
|
+
@router.get('/posts', status_code=HTTPStatus.OK)
|
|
89
|
+
async def list_(
|
|
90
|
+
request: Request,
|
|
91
|
+
db: AsyncSession = Depends(get_session)
|
|
92
|
+
):
|
|
93
|
+
return await DataGrid(db, Post, request).init().get()
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 2. Custom filters
|
|
97
|
+
```python
|
|
98
|
+
# URL: /posts?sort=-created&filter[status]=active&filter[name]=demo&view=paginate
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
from app.models import Post
|
|
102
|
+
from reachcollective.utils import DataGrid
|
|
103
|
+
|
|
104
|
+
@router.get('/posts', status_code=HTTPStatus.OK)
|
|
105
|
+
async def list_(
|
|
106
|
+
request: Request,
|
|
107
|
+
db: AsyncSession = Depends(get_session)
|
|
108
|
+
):
|
|
109
|
+
datagrid = DataGrid(db, Post, request)
|
|
110
|
+
datagrid.qp.filters.personalize = ['name']
|
|
111
|
+
datagrid.init()
|
|
112
|
+
|
|
113
|
+
for key, value in datagrid.params['filters']['customize'].items():
|
|
114
|
+
match key:
|
|
115
|
+
case 'name':
|
|
116
|
+
datagrid.qb.stmt = datagrid.qb.stmt.where(Post.name.ilike(f'%{value}%'))
|
|
117
|
+
|
|
118
|
+
return await datagrid.get()
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### 3. Result
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"current_page": 1,
|
|
125
|
+
"data": [
|
|
126
|
+
{
|
|
127
|
+
"description": "",
|
|
128
|
+
"id": "18a6ac49-cb2f-41c7-ac14-ff5360210c65",
|
|
129
|
+
"name": "demo",
|
|
130
|
+
"status": "active"
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"id": "81f08423-288f-4e74-bcbb-edf5ef37f1fe",
|
|
134
|
+
"name": "demo",
|
|
135
|
+
"status": "active"
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"d": "a017c74f-f0bb-4f65-9faf-36581812bbe7",
|
|
139
|
+
"name": "Test 4",
|
|
140
|
+
"status": "active"
|
|
141
|
+
}
|
|
142
|
+
],
|
|
143
|
+
"total": 683,
|
|
144
|
+
"per_page": 15,
|
|
145
|
+
"total_pages": 46
|
|
146
|
+
}
|
|
147
|
+
```
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# ReachCollective / APIs / Utils
|
|
2
|
+
|
|
3
|
+
# Requirements
|
|
4
|
+
- sqlmodel (>=0.0.22,<0.0.23)
|
|
5
|
+
- fastapi (>=0.115.8,<0.116.0)
|
|
6
|
+
- pydantic (>=2.10.6,<3.0.0)
|
|
7
|
+
|
|
8
|
+
# Installation
|
|
9
|
+
|
|
10
|
+
### Poetry
|
|
11
|
+
```
|
|
12
|
+
poetry add reachcollective-utils
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
### Pip
|
|
16
|
+
```
|
|
17
|
+
pip install reachcollective-utils
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
# Examples
|
|
21
|
+
|
|
22
|
+
## DataGrid
|
|
23
|
+
List and paginate data from a model, accepting filters, sorting, and relationships
|
|
24
|
+
|
|
25
|
+
### 1. Modify models
|
|
26
|
+
Add `to_dcit()` function to each SQL Model, `to_dcit()` gets relationships.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
|
|
30
|
+
class Post(SQLModel, table=True):
|
|
31
|
+
__tablename__ = "posts"
|
|
32
|
+
|
|
33
|
+
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
34
|
+
name: str | None
|
|
35
|
+
status: str | None
|
|
36
|
+
comments: list["Comment"] | None = Relationship(back_populates="post", sa_relationship_kwargs={"lazy": "raise"})
|
|
37
|
+
|
|
38
|
+
def to_dict(self, *args, **kwargs):
|
|
39
|
+
data = super().model_dump(*args, **kwargs)
|
|
40
|
+
if 'comments' in self.__dict__:
|
|
41
|
+
data['comments'] = []
|
|
42
|
+
if self.quota_groups:
|
|
43
|
+
data['comments'] = [quota_group.to_dict(*args, **kwargs) for quota_group in self.quota_groups]
|
|
44
|
+
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Comment(SQLModel, table=True):
|
|
49
|
+
__tablename__ = "comments"
|
|
50
|
+
|
|
51
|
+
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
|
52
|
+
description: str | None
|
|
53
|
+
post: Optional["Survey"] = Relationship(back_populates="comments", sa_relationship_kwargs={"lazy": "raise"})
|
|
54
|
+
|
|
55
|
+
def to_dict(self, *args, **kwargs):
|
|
56
|
+
data = super().model_dump(*args, **kwargs)
|
|
57
|
+
if 'post' in self.__dict__:
|
|
58
|
+
data['post'] = {}
|
|
59
|
+
if self.survey:
|
|
60
|
+
data['post'] = self.survey.model_dump(*args, **kwargs)
|
|
61
|
+
|
|
62
|
+
return data
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 2. Basic use
|
|
66
|
+
```python
|
|
67
|
+
# URL: /posts?sort=-created&filter[status]=active&view=paginate
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
from app.models import Post
|
|
71
|
+
from reachcollective.utils import DataGrid
|
|
72
|
+
|
|
73
|
+
@router.get('/posts', status_code=HTTPStatus.OK)
|
|
74
|
+
async def list_(
|
|
75
|
+
request: Request,
|
|
76
|
+
db: AsyncSession = Depends(get_session)
|
|
77
|
+
):
|
|
78
|
+
return await DataGrid(db, Post, request).init().get()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 2. Custom filters
|
|
82
|
+
```python
|
|
83
|
+
# URL: /posts?sort=-created&filter[status]=active&filter[name]=demo&view=paginate
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
from app.models import Post
|
|
87
|
+
from reachcollective.utils import DataGrid
|
|
88
|
+
|
|
89
|
+
@router.get('/posts', status_code=HTTPStatus.OK)
|
|
90
|
+
async def list_(
|
|
91
|
+
request: Request,
|
|
92
|
+
db: AsyncSession = Depends(get_session)
|
|
93
|
+
):
|
|
94
|
+
datagrid = DataGrid(db, Post, request)
|
|
95
|
+
datagrid.qp.filters.personalize = ['name']
|
|
96
|
+
datagrid.init()
|
|
97
|
+
|
|
98
|
+
for key, value in datagrid.params['filters']['customize'].items():
|
|
99
|
+
match key:
|
|
100
|
+
case 'name':
|
|
101
|
+
datagrid.qb.stmt = datagrid.qb.stmt.where(Post.name.ilike(f'%{value}%'))
|
|
102
|
+
|
|
103
|
+
return await datagrid.get()
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### 3. Result
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"current_page": 1,
|
|
110
|
+
"data": [
|
|
111
|
+
{
|
|
112
|
+
"description": "",
|
|
113
|
+
"id": "18a6ac49-cb2f-41c7-ac14-ff5360210c65",
|
|
114
|
+
"name": "demo",
|
|
115
|
+
"status": "active"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"id": "81f08423-288f-4e74-bcbb-edf5ef37f1fe",
|
|
119
|
+
"name": "demo",
|
|
120
|
+
"status": "active"
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"d": "a017c74f-f0bb-4f65-9faf-36581812bbe7",
|
|
124
|
+
"name": "Test 4",
|
|
125
|
+
"status": "active"
|
|
126
|
+
}
|
|
127
|
+
],
|
|
128
|
+
"total": 683,
|
|
129
|
+
"per_page": 15,
|
|
130
|
+
"total_pages": 46
|
|
131
|
+
}
|
|
132
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "reachcollective-utils"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Utilities for developing RESTful API, Open API projects with FastAPI and Python"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Juan Minaya",email = "minayaleon@gmail.com"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"fastapi[standard] (>=0.115.8,<0.116.0)",
|
|
12
|
+
"sqlmodel (>=0.0.22,<0.0.23)",
|
|
13
|
+
"pydantic (>=2.10.6,<3.0.0)"
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[tool.poetry]
|
|
17
|
+
packages = [{include = "reachcollective", from = "src"}]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
22
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from .datagrid.datagrid import DataGrid
|
|
2
|
+
from .datagrid.filters import Filters
|
|
3
|
+
from .datagrid.query_builder import QueryBuilder
|
|
4
|
+
from .datagrid.query_params import QueryParams
|
|
5
|
+
from .datagrid.paginator import Paginator
|
|
6
|
+
from .render import APIRender
|
|
7
|
+
from .exception import APIException
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from fastapi import Request
|
|
2
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
3
|
+
|
|
4
|
+
from .paginator import Paginator
|
|
5
|
+
from .query_builder import QueryBuilder
|
|
6
|
+
from .query_params import QueryParams
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DataGrid:
|
|
10
|
+
def __init__(self, db: AsyncSession, model, request: Request):
|
|
11
|
+
|
|
12
|
+
self.model = model
|
|
13
|
+
self.db = db
|
|
14
|
+
self.params = {}
|
|
15
|
+
|
|
16
|
+
self.qp: QueryParams = QueryParams(request)
|
|
17
|
+
self.qb: QueryBuilder | None = None
|
|
18
|
+
|
|
19
|
+
def init(self):
|
|
20
|
+
self.params = self.qp.get()
|
|
21
|
+
self.qb = QueryBuilder(self.db, self.model, self.params).init()
|
|
22
|
+
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
async def get(self):
|
|
26
|
+
match self.qp.view:
|
|
27
|
+
case 'paginate':
|
|
28
|
+
return await Paginator(self.qb, self.qp).get()
|
|
29
|
+
case 'count':
|
|
30
|
+
return await self.qb.total()
|
|
31
|
+
case _:
|
|
32
|
+
return await self.qb.get()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
class Filters:
|
|
2
|
+
def __init__(self, qp=None):
|
|
3
|
+
self.qp = qp
|
|
4
|
+
self.init = {}
|
|
5
|
+
self.equals = {}
|
|
6
|
+
self.personalize = []
|
|
7
|
+
self.customize = {}
|
|
8
|
+
|
|
9
|
+
def process(self):
|
|
10
|
+
for k, v in self.qp.items():
|
|
11
|
+
if k.find('filter') >= 0:
|
|
12
|
+
attr = k[k.find('[') + 1 : k.find(']')]
|
|
13
|
+
t = self.process_value(attr, v)
|
|
14
|
+
if attr in self.personalize:
|
|
15
|
+
self.customize = {**self.customize, **t}
|
|
16
|
+
else:
|
|
17
|
+
self.equals = {**self.equals, **t}
|
|
18
|
+
|
|
19
|
+
def get(self):
|
|
20
|
+
r = {'init': self.init, 'equals': self.equals, 'customize': self.customize}
|
|
21
|
+
return r
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def process_value(attr, value):
|
|
25
|
+
parts = value.split('|')
|
|
26
|
+
if len(parts) > 1:
|
|
27
|
+
value = parts
|
|
28
|
+
|
|
29
|
+
return {attr: value}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
from .query_builder import QueryBuilder
|
|
4
|
+
from .query_params import QueryParams
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Paginator:
|
|
8
|
+
def __init__(self, query_builder: QueryBuilder, query_params: QueryParams):
|
|
9
|
+
self.r = self.get_template()
|
|
10
|
+
self.qp = query_params
|
|
11
|
+
self.qb = query_builder
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def get_template() -> dict:
|
|
15
|
+
return {
|
|
16
|
+
'current_page': 1,
|
|
17
|
+
'data': [],
|
|
18
|
+
'total': None,
|
|
19
|
+
'per_page': None,
|
|
20
|
+
'total_pages': None
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async def build(self):
|
|
24
|
+
total = await self.qb.total()
|
|
25
|
+
|
|
26
|
+
# Current Page
|
|
27
|
+
self.r['current_page'] = self.qp.page
|
|
28
|
+
|
|
29
|
+
# Data
|
|
30
|
+
offset = self.qp.size * (self.qp.page - 1)
|
|
31
|
+
self.qb.stmt = self.qb.stmt.offset(offset).limit(self.qp.size)
|
|
32
|
+
self.r['data'] = await self.qb.get()
|
|
33
|
+
|
|
34
|
+
# Total
|
|
35
|
+
self.r['total'] = total
|
|
36
|
+
|
|
37
|
+
# Per Page
|
|
38
|
+
self.r['per_page'] = self.qp.size
|
|
39
|
+
|
|
40
|
+
# Total Page
|
|
41
|
+
self.r['total_pages'] = math.ceil(total / self.qp.size)
|
|
42
|
+
|
|
43
|
+
async def get(self) -> dict:
|
|
44
|
+
await self.build()
|
|
45
|
+
return self.r
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
2
|
+
from sqlmodel import select
|
|
3
|
+
from sqlalchemy.orm import selectinload
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class QueryBuilder:
|
|
7
|
+
def __init__(self, db: AsyncSession, model, params):
|
|
8
|
+
self.model = model
|
|
9
|
+
self.stmt = None
|
|
10
|
+
self.params = params
|
|
11
|
+
self.db = db
|
|
12
|
+
|
|
13
|
+
def init(self):
|
|
14
|
+
filters = {}
|
|
15
|
+
if ('filters' in self.params) and ('equals' in self.params['filters']):
|
|
16
|
+
filters = self.params['filters']['equals']
|
|
17
|
+
if 'init' in self.params['filters']:
|
|
18
|
+
filters = {**filters, **self.params['filters']['init']}
|
|
19
|
+
|
|
20
|
+
self.stmt = select(self.model)
|
|
21
|
+
self.apply_filters(filters)
|
|
22
|
+
self.apply_sort()
|
|
23
|
+
|
|
24
|
+
if self.params['with']:
|
|
25
|
+
relationships = self.params['with']
|
|
26
|
+
self.stmt = self.stmt.options(*[selectinload(getattr(self.model, relation)) for relation in relationships])
|
|
27
|
+
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def apply_filters(self, filters: dict[str, any]):
|
|
31
|
+
for key, value in filters.items():
|
|
32
|
+
if value:
|
|
33
|
+
column = getattr(self.model, key, None)
|
|
34
|
+
if column:
|
|
35
|
+
if isinstance(value, list):
|
|
36
|
+
self.stmt = self.stmt.where(column.in_(value))
|
|
37
|
+
else:
|
|
38
|
+
self.stmt = self.stmt.where(column == value)
|
|
39
|
+
|
|
40
|
+
def apply_sort(self):
|
|
41
|
+
if self.params['sort']:
|
|
42
|
+
col = self.params['sort'][0]['col']
|
|
43
|
+
dir_ = self.params['sort'][0]['dir']
|
|
44
|
+
|
|
45
|
+
column = getattr(self.model, col, None)
|
|
46
|
+
if column:
|
|
47
|
+
if dir_ == 'desc':
|
|
48
|
+
self.stmt = self.stmt.order_by(column.desc())
|
|
49
|
+
else:
|
|
50
|
+
self.stmt = self.stmt.order_by(column)
|
|
51
|
+
|
|
52
|
+
async def get(self, native = False):
|
|
53
|
+
result = await self.db.exec(self.stmt)
|
|
54
|
+
if native:
|
|
55
|
+
return result.all()
|
|
56
|
+
else:
|
|
57
|
+
rows = result.all()
|
|
58
|
+
# Check only the first record, if available
|
|
59
|
+
if rows and (not hasattr(rows[0], 'to_dict') or not callable(getattr(rows[0], 'to_dict', None))):
|
|
60
|
+
return rows
|
|
61
|
+
|
|
62
|
+
# If the first record has `to_dict()`, return the transformed list
|
|
63
|
+
return [o.to_dict() for o in rows]
|
|
64
|
+
|
|
65
|
+
async def total(self) -> int:
|
|
66
|
+
all_ = await self.get(True)
|
|
67
|
+
return len(all_)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from fastapi import Request
|
|
2
|
+
|
|
3
|
+
from .filters import Filters
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class QueryParams:
|
|
7
|
+
def __init__(self, request: Request, qp=None):
|
|
8
|
+
self.qp = qp.copy() if qp else None
|
|
9
|
+
self.filters = Filters(self.qp)
|
|
10
|
+
self.sort = []
|
|
11
|
+
self.view = 'list'
|
|
12
|
+
self.request = request
|
|
13
|
+
self.page = 1
|
|
14
|
+
self.size = 15
|
|
15
|
+
self.r = {}
|
|
16
|
+
|
|
17
|
+
def process_sort(self) -> None:
|
|
18
|
+
if 'sort' in self.qp:
|
|
19
|
+
direction = 'asc'
|
|
20
|
+
col = self.qp['sort']
|
|
21
|
+
if self.qp['sort'].find('-', 0, 1) >= 0:
|
|
22
|
+
direction = 'desc'
|
|
23
|
+
col = self.qp['sort'][1:]
|
|
24
|
+
|
|
25
|
+
self.sort = [{'col': col, 'dir': direction, 'by': self.qp['sort']}]
|
|
26
|
+
|
|
27
|
+
def process(self) -> None:
|
|
28
|
+
self.process_sort()
|
|
29
|
+
|
|
30
|
+
if 'page' in self.qp:
|
|
31
|
+
self.page = int(self.qp['page'])
|
|
32
|
+
|
|
33
|
+
if 'size' in self.qp:
|
|
34
|
+
self.size = int(self.qp['size'])
|
|
35
|
+
|
|
36
|
+
if 'view' in self.qp:
|
|
37
|
+
self.view = self.qp['view']
|
|
38
|
+
|
|
39
|
+
def get(self):
|
|
40
|
+
self.qp = dict(self.request.query_params)
|
|
41
|
+
self.filters.qp = dict(self.request.query_params)
|
|
42
|
+
|
|
43
|
+
# Filters
|
|
44
|
+
self.process()
|
|
45
|
+
self.filters.process()
|
|
46
|
+
filters = self.filters.get()
|
|
47
|
+
if any(filters):
|
|
48
|
+
self.r['filters'] = filters
|
|
49
|
+
|
|
50
|
+
# With
|
|
51
|
+
parts = []
|
|
52
|
+
if 'with' in self.qp:
|
|
53
|
+
parts = self.qp['with'].split(',')
|
|
54
|
+
for idx, x in enumerate(parts):
|
|
55
|
+
if x.find('.'):
|
|
56
|
+
x = x.replace('.', '__')
|
|
57
|
+
|
|
58
|
+
parts[idx] = x.strip()
|
|
59
|
+
|
|
60
|
+
self.r['with'] = parts
|
|
61
|
+
self.r['sort'] = self.sort
|
|
62
|
+
self.r['view'] = self.view
|
|
63
|
+
self.r['page'] = self.page
|
|
64
|
+
self.r['size'] = self.size
|
|
65
|
+
|
|
66
|
+
return self.r
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class APIException(Exception):
|
|
2
|
+
def __init__(self, message: str|None, code: int, detail: dict = None):
|
|
3
|
+
self.message = message
|
|
4
|
+
self.code = code
|
|
5
|
+
self.detail = detail or {}
|
|
6
|
+
|
|
7
|
+
def get_error(self) -> str|dict:
|
|
8
|
+
if self.detail:
|
|
9
|
+
# Check if resposne from External API attribute (response)
|
|
10
|
+
if 'response' in self.detail:
|
|
11
|
+
if self.detail['response']:
|
|
12
|
+
return self.detail['response']
|
|
13
|
+
else:
|
|
14
|
+
return self.message
|
|
15
|
+
|
|
16
|
+
return self.detail
|
|
17
|
+
|
|
18
|
+
return self.message
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from http import HTTPStatus
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import Request, HTTPException
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from .exception import APIException
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class APIRender:
|
|
13
|
+
@staticmethod
|
|
14
|
+
def display(message, code=200):
|
|
15
|
+
message = APIRender.clean_message(message)
|
|
16
|
+
response = message if isinstance(message, dict) else {'title': message}
|
|
17
|
+
response = APIRender.format_message(response, code)
|
|
18
|
+
return JSONResponse(
|
|
19
|
+
status_code=code,
|
|
20
|
+
content=response
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def format_message(message: dict, code: int):
|
|
25
|
+
formatted_message = {
|
|
26
|
+
'title': '',
|
|
27
|
+
'type': 'about:blank',
|
|
28
|
+
'detail': None,
|
|
29
|
+
'status': code,
|
|
30
|
+
'instance': '',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if 'title' in message:
|
|
34
|
+
formatted_message['title'] = message['title']
|
|
35
|
+
|
|
36
|
+
if not formatted_message['title'] and 'message' in message:
|
|
37
|
+
formatted_message['title'] = message['message']
|
|
38
|
+
|
|
39
|
+
if 'type' in message:
|
|
40
|
+
formatted_message['type'] = message['type']
|
|
41
|
+
|
|
42
|
+
if 'detail' in message:
|
|
43
|
+
# TSL Fix
|
|
44
|
+
if 'title' not in message and isinstance(message['detail'], str):
|
|
45
|
+
formatted_message['title'] = message['detail']
|
|
46
|
+
else:
|
|
47
|
+
formatted_message['detail'] = message['detail']
|
|
48
|
+
|
|
49
|
+
if 'status' in message:
|
|
50
|
+
formatted_message['status'] = message['status']
|
|
51
|
+
|
|
52
|
+
if 'instance' in message:
|
|
53
|
+
formatted_message['instance'] = message['instance']
|
|
54
|
+
|
|
55
|
+
return formatted_message
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def clean_message(message) -> str|dict:
|
|
59
|
+
# Check if empty
|
|
60
|
+
if not message:
|
|
61
|
+
return ''
|
|
62
|
+
|
|
63
|
+
# Check if Dictionary
|
|
64
|
+
if isinstance(message, dict):
|
|
65
|
+
return message
|
|
66
|
+
|
|
67
|
+
# Check if JSON String
|
|
68
|
+
try:
|
|
69
|
+
return json.loads(message)
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
return message
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def error(message=None, code=500):
|
|
75
|
+
return APIRender.display(message, code)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def get_plain_message(message=None, code=500) -> str:
|
|
79
|
+
message = APIRender.clean_message(message)
|
|
80
|
+
|
|
81
|
+
# RCP format 1
|
|
82
|
+
if 'detail' in message:
|
|
83
|
+
message = message['detail']
|
|
84
|
+
|
|
85
|
+
# RCP format 2
|
|
86
|
+
if 'message' in message:
|
|
87
|
+
message = message['message']
|
|
88
|
+
|
|
89
|
+
if not message:
|
|
90
|
+
match code:
|
|
91
|
+
case 401:
|
|
92
|
+
message = 'Unauthorized.'
|
|
93
|
+
case _:
|
|
94
|
+
message = 'Unknown error.'
|
|
95
|
+
|
|
96
|
+
return message
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def request_errors(e) -> JSONResponse:
|
|
100
|
+
message = 'The given data was invalid.'
|
|
101
|
+
formatted_errors = APIRender.clean_data(APIRender.process_errors(e))
|
|
102
|
+
first_error = formatted_errors[0]
|
|
103
|
+
if 'loc' in first_error and 'type' in first_error:
|
|
104
|
+
loc = first_error['loc']
|
|
105
|
+
type_ = first_error['type']
|
|
106
|
+
message = f'Field {loc} is {type_}.' if loc != 'body' else f'Body is {type_}.'
|
|
107
|
+
|
|
108
|
+
return APIRender.display({'title': message, 'detail': ''}, 422)
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def request_validation_handler(_: Request, e: ValidationError) -> JSONResponse:
|
|
112
|
+
return APIRender.request_errors(e)
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def http_exception_handler(_: Request, e: HTTPException):
|
|
116
|
+
message = APIRender.get_plain_message(e.detail, e.status_code)
|
|
117
|
+
|
|
118
|
+
if e.status_code == HTTPStatus.UNAUTHORIZED:
|
|
119
|
+
APIRender.display('Unauthorized.', HTTPStatus.UNAUTHORIZED)
|
|
120
|
+
|
|
121
|
+
return APIRender.display(message, e.status_code)
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def app_exception_handler(_: Request, e: APIException):
|
|
125
|
+
return APIRender.display(e.get_error(), e.code)
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def not_found(resource):
|
|
129
|
+
message = f'{resource} not found.'
|
|
130
|
+
return APIRender.display(message, 404)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def loc_to_dot_sep(loc: tuple[str | int, ...]) -> str:
|
|
134
|
+
path = ''
|
|
135
|
+
for i, x in enumerate(loc):
|
|
136
|
+
if isinstance(x, str):
|
|
137
|
+
if i > 0:
|
|
138
|
+
path += '.'
|
|
139
|
+
path += x
|
|
140
|
+
elif isinstance(x, int):
|
|
141
|
+
path += f'[{x}]'
|
|
142
|
+
else:
|
|
143
|
+
raise TypeError('Unexpected type')
|
|
144
|
+
return path
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def process_errors(e: ValidationError) -> list[dict[str, Any]]:
|
|
148
|
+
new_errors: list[dict[str, Any]] = e.errors()
|
|
149
|
+
for error in new_errors:
|
|
150
|
+
error['loc'] = APIRender.loc_to_dot_sep(error['loc'])
|
|
151
|
+
return new_errors
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def clean_data(data):
|
|
155
|
+
cleaned_data = []
|
|
156
|
+
for item in data:
|
|
157
|
+
cleaned_item = item.copy()
|
|
158
|
+
if isinstance(cleaned_item.get('input'), type):
|
|
159
|
+
cleaned_item['input'] = str(cleaned_item['input'])
|
|
160
|
+
cleaned_data.append(cleaned_item)
|
|
161
|
+
return cleaned_data
|