fastapi-rtk 0.2.60__py3-none-any.whl → 1.0.18__py3-none-any.whl
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_rtk/__init__.py +0 -1
- fastapi_rtk/_version.py +1 -0
- fastapi_rtk/api/model_rest_api.py +182 -87
- fastapi_rtk/auth/auth.py +0 -9
- fastapi_rtk/backends/sqla/db.py +32 -7
- fastapi_rtk/backends/sqla/filters.py +16 -0
- fastapi_rtk/backends/sqla/interface.py +11 -62
- fastapi_rtk/backends/sqla/model.py +16 -1
- fastapi_rtk/bases/db.py +20 -2
- fastapi_rtk/bases/file_manager.py +12 -0
- fastapi_rtk/bases/filter.py +1 -1
- fastapi_rtk/cli/cli.py +61 -0
- fastapi_rtk/cli/commands/security.py +6 -6
- fastapi_rtk/const.py +1 -1
- fastapi_rtk/db.py +3 -0
- fastapi_rtk/dependencies.py +110 -64
- fastapi_rtk/fastapi_react_toolkit.py +123 -172
- fastapi_rtk/file_managers/s3_file_manager.py +63 -32
- fastapi_rtk/lang/messages.pot +12 -12
- fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.mo +0 -0
- fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.po +12 -12
- fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.mo +0 -0
- fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.po +12 -12
- fastapi_rtk/manager.py +10 -14
- fastapi_rtk/schemas.py +6 -4
- fastapi_rtk/security/sqla/apis.py +20 -5
- fastapi_rtk/security/sqla/models.py +8 -23
- fastapi_rtk/security/sqla/security_manager.py +367 -10
- fastapi_rtk/utils/async_task_runner.py +119 -30
- fastapi_rtk/utils/csv_json_converter.py +242 -39
- fastapi_rtk/utils/hooks.py +7 -4
- fastapi_rtk/utils/self_dependencies.py +1 -1
- fastapi_rtk/version.py +6 -1
- fastapi_rtk-1.0.18.dist-info/METADATA +28 -0
- {fastapi_rtk-0.2.60.dist-info → fastapi_rtk-1.0.18.dist-info}/RECORD +38 -38
- {fastapi_rtk-0.2.60.dist-info → fastapi_rtk-1.0.18.dist-info}/WHEEL +1 -2
- fastapi_rtk-0.2.60.dist-info/METADATA +0 -25
- fastapi_rtk-0.2.60.dist-info/top_level.txt +0 -1
- {fastapi_rtk-0.2.60.dist-info → fastapi_rtk-1.0.18.dist-info}/entry_points.txt +0 -0
- {fastapi_rtk-0.2.60.dist-info → fastapi_rtk-1.0.18.dist-info}/licenses/LICENSE +0 -0
|
@@ -7,9 +7,7 @@ from typing import Annotated, Literal, Type
|
|
|
7
7
|
|
|
8
8
|
import fastapi
|
|
9
9
|
import marshmallow_sqlalchemy
|
|
10
|
-
import pydantic
|
|
11
10
|
import sqlalchemy
|
|
12
|
-
import sqlalchemy.exc
|
|
13
11
|
from pydantic import (
|
|
14
12
|
AfterValidator,
|
|
15
13
|
BeforeValidator,
|
|
@@ -45,7 +43,6 @@ from ...utils import (
|
|
|
45
43
|
lazy_import,
|
|
46
44
|
lazy_self,
|
|
47
45
|
safe_call,
|
|
48
|
-
safe_call_sync,
|
|
49
46
|
smart_run,
|
|
50
47
|
)
|
|
51
48
|
from .db import SQLAQueryBuilder
|
|
@@ -258,7 +255,12 @@ class SQLAInterface(AbstractInterface[ModelType, Session | AsyncSession, Column]
|
|
|
258
255
|
unique_order_columns.update(
|
|
259
256
|
[f"{col_name}.{sub_col}" for sub_col in sub_order_columns]
|
|
260
257
|
)
|
|
261
|
-
elif
|
|
258
|
+
elif (
|
|
259
|
+
self.is_property(col_name)
|
|
260
|
+
and not self.is_hybrid_property(col_name)
|
|
261
|
+
or self.is_files(col_name)
|
|
262
|
+
or self.is_images(col_name)
|
|
263
|
+
):
|
|
262
264
|
continue
|
|
263
265
|
|
|
264
266
|
# Allow the column to be used for ordering by default
|
|
@@ -356,14 +358,12 @@ class SQLAInterface(AbstractInterface[ModelType, Session | AsyncSession, Column]
|
|
|
356
358
|
]
|
|
357
359
|
elif self.is_file(col) or self.is_image(col):
|
|
358
360
|
value_type = fastapi.UploadFile
|
|
361
|
+
annotated_str_type = typing.Annotated[
|
|
362
|
+
str | None, BeforeValidator(lambda x: None if x == "null" else x)
|
|
363
|
+
]
|
|
359
364
|
if self.is_files(col) or self.is_images(col):
|
|
360
|
-
value_type = list[value_type |
|
|
361
|
-
return
|
|
362
|
-
value_type
|
|
363
|
-
| typing.Annotated[
|
|
364
|
-
str | None, BeforeValidator(lambda x: None if x == "null" else x)
|
|
365
|
-
]
|
|
366
|
-
)
|
|
365
|
+
value_type = list[value_type | annotated_str_type]
|
|
366
|
+
return value_type | annotated_str_type
|
|
367
367
|
elif self.is_date(col):
|
|
368
368
|
return date
|
|
369
369
|
elif self.is_datetime(col):
|
|
@@ -682,57 +682,6 @@ class SQLAInterface(AbstractInterface[ModelType, Session | AsyncSession, Column]
|
|
|
682
682
|
|
|
683
683
|
return result
|
|
684
684
|
|
|
685
|
-
def _generate_schema_from_dict(self, schema_dict):
|
|
686
|
-
# Allow await for deferrable columns
|
|
687
|
-
deferrable_columns = [
|
|
688
|
-
key for key in schema_dict.keys() if key in self.list_properties
|
|
689
|
-
]
|
|
690
|
-
model_name = schema_dict.get("__name__", "UnknownModel")
|
|
691
|
-
if deferrable_columns:
|
|
692
|
-
|
|
693
|
-
async def fill_deferrable(model: Model, col: str):
|
|
694
|
-
try:
|
|
695
|
-
return getattr(model, col)
|
|
696
|
-
except sqlalchemy.exc.MissingGreenlet:
|
|
697
|
-
logger.warning(
|
|
698
|
-
f"'MissingGreenlet' error when accessing {model.__class__.__name__}.{col} to create pydantic model {model_name}, trying to await it instead."
|
|
699
|
-
)
|
|
700
|
-
try:
|
|
701
|
-
await getattr(model.awaitable_attrs, col)
|
|
702
|
-
except Exception as e:
|
|
703
|
-
return_value = (
|
|
704
|
-
[]
|
|
705
|
-
if self.is_relation_one_to_many(col)
|
|
706
|
-
or self.is_relation_many_to_many(col)
|
|
707
|
-
else None
|
|
708
|
-
)
|
|
709
|
-
logger.error(
|
|
710
|
-
f"Error when awaiting {model.__class__.__name__}.{col} to create pydantic model {model_name}, returning {return_value} instead: {e}"
|
|
711
|
-
)
|
|
712
|
-
return return_value
|
|
713
|
-
return getattr(model, col)
|
|
714
|
-
|
|
715
|
-
async def fill_deferables(data: Model):
|
|
716
|
-
async with AsyncTaskRunner():
|
|
717
|
-
for col in deferrable_columns:
|
|
718
|
-
AsyncTaskRunner.add_task(
|
|
719
|
-
lambda col=col: fill_deferrable(data, col)
|
|
720
|
-
)
|
|
721
|
-
|
|
722
|
-
def fill_deferrables_validator(data):
|
|
723
|
-
if isinstance(data, Model):
|
|
724
|
-
safe_call_sync(fill_deferables(data))
|
|
725
|
-
return data
|
|
726
|
-
|
|
727
|
-
decorated_func = pydantic.model_validator(mode="before")(
|
|
728
|
-
fill_deferrables_validator
|
|
729
|
-
)
|
|
730
|
-
schema_dict["__validators__"] = schema_dict.get("__validators__", {})
|
|
731
|
-
schema_dict["__validators__"][fill_deferrables_validator.__name__] = (
|
|
732
|
-
decorated_func
|
|
733
|
-
)
|
|
734
|
-
return super()._generate_schema_from_dict(schema_dict)
|
|
735
|
-
|
|
736
685
|
"""
|
|
737
686
|
--------------------------------------------------------------------------------------------------------
|
|
738
687
|
SQLA RELATED METHODS - ONLY IN SQLAInterface
|
|
@@ -13,7 +13,7 @@ from sqlalchemy.util.typing import Literal
|
|
|
13
13
|
from ...bases.model import BasicModel
|
|
14
14
|
from ...const import DEFAULT_METADATA_KEY
|
|
15
15
|
from ...exceptions import FastAPIReactToolkitException
|
|
16
|
-
from ...utils import smart_run_sync
|
|
16
|
+
from ...utils import AsyncTaskRunner, smart_run_sync
|
|
17
17
|
|
|
18
18
|
__all__ = ["Model", "metadata", "metadatas", "Base"]
|
|
19
19
|
|
|
@@ -125,6 +125,21 @@ class Model(sqlalchemy.ext.asyncio.AsyncAttrs, BasicModel, DeclarativeBase):
|
|
|
125
125
|
def get_pk_attrs(cls):
|
|
126
126
|
return [col.name for col in cls.__mapper__.primary_key]
|
|
127
127
|
|
|
128
|
+
async def load(self, *cols: list[str] | str):
|
|
129
|
+
"""
|
|
130
|
+
Asynchronously loads the specified columns of the model instance.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
*cols (list[str] | str): The columns to load. Can be a list of strings or individual string arguments.
|
|
134
|
+
"""
|
|
135
|
+
cols = [
|
|
136
|
+
item for col in cols for item in (col if isinstance(col, list) else [col])
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
async with AsyncTaskRunner() as runner:
|
|
140
|
+
for col in cols:
|
|
141
|
+
runner.add_task(getattr(self.awaitable_attrs, col))
|
|
142
|
+
|
|
128
143
|
|
|
129
144
|
class Table(SA_Table):
|
|
130
145
|
"""
|
fastapi_rtk/bases/db.py
CHANGED
|
@@ -55,9 +55,15 @@ class DBQueryParams(typing.TypedDict):
|
|
|
55
55
|
where_in: tuple[str, list[typing.Any]] | None
|
|
56
56
|
where_id: PRIMARY_KEY | None
|
|
57
57
|
where_id_in: list[PRIMARY_KEY] | None
|
|
58
|
-
filters:
|
|
58
|
+
filters: (
|
|
59
|
+
list[FilterSchema | dict[str, typing.Any] | tuple[str, str, typing.Any] | list]
|
|
60
|
+
| None
|
|
61
|
+
)
|
|
59
62
|
filter_classes: list[tuple[str, AbstractBaseFilter, typing.Any]] | None
|
|
60
|
-
opr_filters:
|
|
63
|
+
opr_filters: (
|
|
64
|
+
list[OprFilterSchema | dict[str, typing.Any] | tuple[str, typing.Any] | list]
|
|
65
|
+
| None
|
|
66
|
+
)
|
|
61
67
|
opr_filter_classes: list[tuple[AbstractBaseFilter, typing.Any]] | None
|
|
62
68
|
global_filter: tuple[list[str], str] | None
|
|
63
69
|
|
|
@@ -182,6 +188,12 @@ class AbstractQueryBuilder(abc.ABC, typing.Generic[T]):
|
|
|
182
188
|
)
|
|
183
189
|
elif step == "filters" and params.get("filters"):
|
|
184
190
|
for filter in params["filters"]:
|
|
191
|
+
if isinstance(filter, dict):
|
|
192
|
+
filter = FilterSchema(**filter)
|
|
193
|
+
elif isinstance(filter, (list, tuple)):
|
|
194
|
+
filter = FilterSchema(
|
|
195
|
+
col=filter[0], opr=filter[1], value=filter[2]
|
|
196
|
+
)
|
|
185
197
|
statement = await safe_call(self.apply_filter(statement, filter))
|
|
186
198
|
elif step == "filter_classes" and params.get("filter_classes"):
|
|
187
199
|
for filter_class in params["filter_classes"]:
|
|
@@ -190,6 +202,12 @@ class AbstractQueryBuilder(abc.ABC, typing.Generic[T]):
|
|
|
190
202
|
)
|
|
191
203
|
elif step == "opr_filters" and params.get("opr_filters"):
|
|
192
204
|
for opr_filter in params["opr_filters"]:
|
|
205
|
+
if isinstance(opr_filter, dict):
|
|
206
|
+
opr_filter = OprFilterSchema(**opr_filter)
|
|
207
|
+
elif isinstance(opr_filter, (list, tuple)):
|
|
208
|
+
opr_filter = OprFilterSchema(
|
|
209
|
+
opr=opr_filter[0], value=opr_filter[1]
|
|
210
|
+
)
|
|
193
211
|
statement = await safe_call(
|
|
194
212
|
self.apply_opr_filter(statement, opr_filter)
|
|
195
213
|
)
|
|
@@ -35,6 +35,18 @@ class AbstractFileManager(abc.ABC):
|
|
|
35
35
|
namegen: typing.Callable[[str], str] | None = None,
|
|
36
36
|
permission: int | None = None,
|
|
37
37
|
):
|
|
38
|
+
"""
|
|
39
|
+
Initializes the AbstractFileManager.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
base_path (str | None, optional): Base path for file storage. Defaults to None.
|
|
43
|
+
allowed_extensions (list[str] | None, optional): Allowed file extensions. Defaults to None.
|
|
44
|
+
namegen (typing.Callable[[str], str] | None, optional): Callable for generating file names. Defaults to None.
|
|
45
|
+
permission (int | None, optional): File permission settings. Defaults to None.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ValueError: If `base_path` is not set.
|
|
49
|
+
"""
|
|
38
50
|
if base_path is not None:
|
|
39
51
|
self.base_path = base_path
|
|
40
52
|
if allowed_extensions is not None:
|
fastapi_rtk/bases/filter.py
CHANGED
fastapi_rtk/cli/cli.py
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import typing
|
|
3
|
+
import zipfile
|
|
1
4
|
from pathlib import Path
|
|
2
5
|
from typing import Annotated, Union
|
|
3
6
|
|
|
7
|
+
import httpx
|
|
4
8
|
import typer
|
|
5
9
|
|
|
6
10
|
app = typer.Typer(rich_markup_mode="rich")
|
|
@@ -30,5 +34,62 @@ def callback(
|
|
|
30
34
|
"""
|
|
31
35
|
|
|
32
36
|
|
|
37
|
+
@app.command()
|
|
38
|
+
def create_app(
|
|
39
|
+
directory: typing.Annotated[
|
|
40
|
+
str,
|
|
41
|
+
typer.Option(
|
|
42
|
+
...,
|
|
43
|
+
help="The directory where the new FastAPI RTK application will be created.",
|
|
44
|
+
),
|
|
45
|
+
] = ".",
|
|
46
|
+
):
|
|
47
|
+
"""
|
|
48
|
+
Create a new FastAPI RTK application with a predefined structure from https://codeberg.org/datatactics/fastapi-rtk-skeleton.
|
|
49
|
+
|
|
50
|
+
This command sets up the necessary files and directories for a FastAPI RTK project,
|
|
51
|
+
allowing you to get started quickly with development.
|
|
52
|
+
"""
|
|
53
|
+
with httpx.Client() as client:
|
|
54
|
+
url = "https://codeberg.org/datatactics/fastapi-rtk-skeleton/archive/main.zip"
|
|
55
|
+
response = client.get(url)
|
|
56
|
+
response.raise_for_status()
|
|
57
|
+
|
|
58
|
+
# Unzip the content into the specified directory
|
|
59
|
+
with zipfile.ZipFile(io.BytesIO(response.content)) as zip_ref:
|
|
60
|
+
# Get all members (files/folders) in the zip
|
|
61
|
+
members = zip_ref.namelist()
|
|
62
|
+
|
|
63
|
+
# Find the top-level directory name
|
|
64
|
+
top_level_dir = members[0].split("/")[0] + "/"
|
|
65
|
+
|
|
66
|
+
# Extract each file, stripping the top-level directory
|
|
67
|
+
for member in members:
|
|
68
|
+
# Skip the top-level directory itself
|
|
69
|
+
if member == top_level_dir:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
# Get the path without the top-level directory
|
|
73
|
+
target_path = member[len(top_level_dir) :]
|
|
74
|
+
|
|
75
|
+
# Skip if empty (this was the root folder)
|
|
76
|
+
if not target_path:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
# Get the source file
|
|
80
|
+
source = zip_ref.open(member)
|
|
81
|
+
target_file = Path(directory) / target_path
|
|
82
|
+
|
|
83
|
+
# Create directories if needed
|
|
84
|
+
if member.endswith("/"):
|
|
85
|
+
target_file.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
else:
|
|
87
|
+
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
with open(target_file, "wb") as f:
|
|
89
|
+
f.write(source.read())
|
|
90
|
+
|
|
91
|
+
typer.echo(f"FastAPI RTK application skeleton created in {directory}")
|
|
92
|
+
|
|
93
|
+
|
|
33
94
|
def main():
|
|
34
95
|
app()
|
|
@@ -238,7 +238,7 @@ async def _check_roles(name: str, create: bool = False):
|
|
|
238
238
|
if not role:
|
|
239
239
|
if not create:
|
|
240
240
|
raise Exception(f"Role {name} does not exist")
|
|
241
|
-
await g.current_app.
|
|
241
|
+
await g.current_app.sm.create_role(name=name, session=session)
|
|
242
242
|
|
|
243
243
|
|
|
244
244
|
async def _create_user(
|
|
@@ -255,7 +255,7 @@ async def _create_user(
|
|
|
255
255
|
"""
|
|
256
256
|
if role:
|
|
257
257
|
await _check_roles(role, create=create_role)
|
|
258
|
-
return await g.current_app.
|
|
258
|
+
return await g.current_app.sm.create_user(
|
|
259
259
|
email=email,
|
|
260
260
|
username=username,
|
|
261
261
|
password=password,
|
|
@@ -269,8 +269,8 @@ async def _reset_password(email_or_username: str, password: str):
|
|
|
269
269
|
"""
|
|
270
270
|
Reset user password.
|
|
271
271
|
"""
|
|
272
|
-
user = await g.current_app.
|
|
273
|
-
return await g.current_app.
|
|
272
|
+
user = await g.current_app.sm.get_user(email_or_username)
|
|
273
|
+
return await g.current_app.sm.reset_password(user, password)
|
|
274
274
|
|
|
275
275
|
|
|
276
276
|
async def export_data(
|
|
@@ -281,7 +281,7 @@ async def export_data(
|
|
|
281
281
|
"""
|
|
282
282
|
Export data.
|
|
283
283
|
"""
|
|
284
|
-
data = await g.current_app.
|
|
284
|
+
data = await g.current_app.sm.export_data(data, type)
|
|
285
285
|
with open(file_path, "w") as f:
|
|
286
286
|
f.write(data)
|
|
287
287
|
|
|
@@ -290,4 +290,4 @@ async def _cleanup():
|
|
|
290
290
|
"""
|
|
291
291
|
Cleanup unused permissions from apis and roles.
|
|
292
292
|
"""
|
|
293
|
-
await g.current_app.
|
|
293
|
+
await g.current_app.sm.cleanup()
|
fastapi_rtk/const.py
CHANGED
|
@@ -76,7 +76,7 @@ DEFAULT_COOKIE_NAME = "dataTactics"
|
|
|
76
76
|
DEFAULT_BASEDIR = "app"
|
|
77
77
|
DEFAULT_STATIC_FOLDER = DEFAULT_BASEDIR + "/static"
|
|
78
78
|
DEFAULT_TEMPLATE_FOLDER = DEFAULT_BASEDIR + "/templates"
|
|
79
|
-
DEFAULT_PROFILER_FOLDER = DEFAULT_STATIC_FOLDER + "/
|
|
79
|
+
DEFAULT_PROFILER_FOLDER = DEFAULT_STATIC_FOLDER + "/profiler"
|
|
80
80
|
DEFAULT_LANG_FOLDER = DEFAULT_BASEDIR + "/lang"
|
|
81
81
|
DEFAULT_LANGUAGES = "en,de"
|
|
82
82
|
DEFAULT_TRANSLATIONS_KEY = "translations"
|
fastapi_rtk/db.py
CHANGED
|
@@ -9,6 +9,7 @@ from typing import (
|
|
|
9
9
|
Optional,
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
+
import sqlalchemy.orm
|
|
12
13
|
from fastapi import Depends
|
|
13
14
|
from fastapi_users.db import SQLAlchemyUserDatabase
|
|
14
15
|
from fastapi_users.models import ID, OAP, UP
|
|
@@ -91,6 +92,7 @@ class UserDatabase(SQLAlchemyUserDatabase[UP, ID]):
|
|
|
91
92
|
|
|
92
93
|
statement = (
|
|
93
94
|
select(self.user_table)
|
|
95
|
+
.options(sqlalchemy.orm.selectinload(self.user_table.oauth_accounts))
|
|
94
96
|
.join(self.oauth_account_table)
|
|
95
97
|
.where(self.oauth_account_table.oauth_name == oauth)
|
|
96
98
|
.where(self.oauth_account_table.account_id == account_id)
|
|
@@ -121,6 +123,7 @@ class UserDatabase(SQLAlchemyUserDatabase[UP, ID]):
|
|
|
121
123
|
raise NotImplementedError()
|
|
122
124
|
|
|
123
125
|
await safe_call(self.session.refresh(user))
|
|
126
|
+
await user.load("oauth_accounts")
|
|
124
127
|
oauth_account = self.oauth_account_table(**create_dict)
|
|
125
128
|
self.session.add(oauth_account)
|
|
126
129
|
user.oauth_accounts.append(oauth_account)
|
fastapi_rtk/dependencies.py
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import fastapi
|
|
2
|
+
import sqlalchemy
|
|
2
3
|
from fastapi import Depends, HTTPException
|
|
3
4
|
|
|
4
5
|
from .api.base_api import BaseApi
|
|
5
|
-
from .const import PERMISSION_PREFIX, ErrorCode
|
|
6
|
+
from .const import PERMISSION_PREFIX, ErrorCode, logger
|
|
7
|
+
from .db import db
|
|
6
8
|
from .globals import g
|
|
7
|
-
from .security.sqla.models import PermissionApi, User
|
|
9
|
+
from .security.sqla.models import Api, Permission, PermissionApi, Role, User
|
|
10
|
+
from .utils import smart_run
|
|
8
11
|
|
|
9
12
|
__all__ = [
|
|
10
13
|
"set_global_user",
|
|
11
|
-
"permissions",
|
|
12
14
|
"current_permissions",
|
|
13
15
|
"has_access_dependency",
|
|
14
16
|
]
|
|
@@ -62,82 +64,65 @@ def check_g_user():
|
|
|
62
64
|
return check_g_user_dependency
|
|
63
65
|
|
|
64
66
|
|
|
65
|
-
def
|
|
67
|
+
def current_permissions(api: BaseApi):
|
|
66
68
|
"""
|
|
67
|
-
A dependency for FastAPI that will return all permissions of the current user.
|
|
69
|
+
A dependency for FastAPI that will return all permissions of the current user for the specified API.
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
Because it will implicitly check whether the user is authenticated, it can return `401 Unauthorized` or `403 Forbidden`.
|
|
70
72
|
|
|
71
73
|
Args:
|
|
72
|
-
|
|
74
|
+
api (BaseApi): The API to be checked.
|
|
73
75
|
|
|
74
76
|
Usage:
|
|
75
77
|
```python
|
|
76
78
|
async def get_info(
|
|
77
79
|
*,
|
|
78
|
-
permissions: List[str] = Depends(
|
|
80
|
+
permissions: List[str] = Depends(current_permissions(self)),
|
|
79
81
|
session: AsyncSession | Session = Depends(get_async_session),
|
|
80
82
|
):
|
|
81
83
|
...more code
|
|
82
84
|
```
|
|
83
85
|
"""
|
|
84
86
|
|
|
85
|
-
async def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
if not g.user.roles:
|
|
93
|
-
raise HTTPException(
|
|
94
|
-
fastapi.status.HTTP_403_FORBIDDEN, ErrorCode.GET_USER_NO_ROLES
|
|
95
|
-
)
|
|
87
|
+
async def current_permissions_depedency(_=Depends(_ensure_roles)):
|
|
88
|
+
sm = g.current_app.sm
|
|
89
|
+
api_name = api.__class__.__name__
|
|
90
|
+
permissions = set[str]()
|
|
91
|
+
db_role_ids = list[int]()
|
|
96
92
|
|
|
97
|
-
permissions
|
|
93
|
+
# Retrieve permissions from built-in roles
|
|
98
94
|
for role in g.user.roles:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
else:
|
|
103
|
-
permissions.append(permission_api.api.name)
|
|
104
|
-
permissions = list(set(permissions))
|
|
105
|
-
|
|
106
|
-
return permissions
|
|
107
|
-
|
|
108
|
-
return permissions_depedency
|
|
95
|
+
if role.name not in sm.builtin_roles:
|
|
96
|
+
db_role_ids.append(role.id)
|
|
97
|
+
continue
|
|
109
98
|
|
|
99
|
+
api_permission_tuples = sm.get_api_permission_tuples_from_builtin_roles(
|
|
100
|
+
role.name
|
|
101
|
+
)
|
|
102
|
+
for multi_apis_str, multi_perms_str in api_permission_tuples:
|
|
103
|
+
api_names = multi_apis_str.split("|")
|
|
104
|
+
perm_names = multi_perms_str.split("|")
|
|
105
|
+
if api_name in api_names:
|
|
106
|
+
permissions.update(perm_names)
|
|
107
|
+
|
|
108
|
+
if db_role_ids:
|
|
109
|
+
query = (
|
|
110
|
+
sqlalchemy.select(Permission)
|
|
111
|
+
.join(PermissionApi)
|
|
112
|
+
.join(PermissionApi.roles)
|
|
113
|
+
.join(Api)
|
|
114
|
+
.where(Api.name == api_name, Role.id.in_(db_role_ids))
|
|
115
|
+
)
|
|
116
|
+
if api.base_permissions:
|
|
117
|
+
query = query.where(Permission.name.in_(api.base_permissions))
|
|
110
118
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
A dependency for FastAPI that will return all permissions of the current user for the specified API.
|
|
114
|
-
|
|
115
|
-
Because it will implicitly call the `permissions` dependency, it can return `403 Forbidden` if the user is not authenticated.
|
|
116
|
-
|
|
117
|
-
Args:
|
|
118
|
-
api (BaseApi): The API to be checked.
|
|
119
|
-
|
|
120
|
-
Usage:
|
|
121
|
-
```python
|
|
122
|
-
async def get_info(
|
|
123
|
-
*,
|
|
124
|
-
permissions: List[str] = Depends(current_permissions(self)),
|
|
125
|
-
session: AsyncSession | Session = Depends(get_async_session),
|
|
126
|
-
):
|
|
127
|
-
...more code
|
|
128
|
-
```
|
|
129
|
-
"""
|
|
119
|
+
permissions_in_db = await smart_run(db.current_session.scalars, query)
|
|
120
|
+
permissions.update(perm.name for perm in permissions_in_db.all())
|
|
130
121
|
|
|
131
|
-
async def current_permissions_depedency(
|
|
132
|
-
permissions_apis: list[PermissionApi] = Depends(permissions(as_object=True)),
|
|
133
|
-
):
|
|
134
|
-
permissions = []
|
|
135
|
-
for permission_api in permissions_apis:
|
|
136
|
-
if api.__class__.__name__ in permission_api.api.name.split("|"):
|
|
137
|
-
permissions = permissions + permission_api.permission.name.split("|")
|
|
138
122
|
if api.base_permissions:
|
|
139
|
-
permissions =
|
|
140
|
-
|
|
123
|
+
permissions = permissions.intersection(set(api.base_permissions))
|
|
124
|
+
|
|
125
|
+
return list(permissions)
|
|
141
126
|
|
|
142
127
|
return current_permissions_depedency
|
|
143
128
|
|
|
@@ -149,7 +134,7 @@ def has_access_dependency(
|
|
|
149
134
|
"""
|
|
150
135
|
A dependency for FastAPI to check whether current user has access to the specified API and permission.
|
|
151
136
|
|
|
152
|
-
Because it will implicitly
|
|
137
|
+
Because it will implicitly check whether the user is authenticated, it can return `401 Unauthorized` or `403 Forbidden`.
|
|
153
138
|
|
|
154
139
|
Usage:
|
|
155
140
|
```python
|
|
@@ -165,15 +150,55 @@ def has_access_dependency(
|
|
|
165
150
|
api (BaseApi): The API to be checked.
|
|
166
151
|
permission (str): The permission to check.
|
|
167
152
|
"""
|
|
153
|
+
permission = f"{PERMISSION_PREFIX}{permission}"
|
|
168
154
|
|
|
169
|
-
async def check_permission(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
155
|
+
async def check_permission():
|
|
156
|
+
_ensure_roles(ErrorCode.PERMISSION_DENIED)
|
|
157
|
+
sm = g.current_app.sm
|
|
158
|
+
|
|
159
|
+
# First, check built-in roles (avoiding unnecessary DB queries)
|
|
160
|
+
# This also covers the case for API and permission name with pipes
|
|
161
|
+
if any(
|
|
162
|
+
sm.has_access_in_builtin_roles(
|
|
163
|
+
role.name, api.__class__.__name__, permission
|
|
164
|
+
)
|
|
165
|
+
for role in g.user.roles
|
|
166
|
+
):
|
|
167
|
+
logger.debug(
|
|
168
|
+
f"User {g.user} has access to {api.__class__.__name__} with permission {permission} via built-in roles."
|
|
169
|
+
)
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
db_role_ids = [
|
|
173
|
+
role.id for role in g.user.roles if role.name not in sm.builtin_roles
|
|
174
|
+
]
|
|
175
|
+
if not db_role_ids:
|
|
173
176
|
raise HTTPException(
|
|
174
177
|
fastapi.status.HTTP_403_FORBIDDEN, ErrorCode.PERMISSION_DENIED
|
|
175
178
|
)
|
|
176
|
-
|
|
179
|
+
|
|
180
|
+
api_name = api.__class__.__name__
|
|
181
|
+
stmt = (
|
|
182
|
+
sqlalchemy.select(sqlalchemy.literal(True))
|
|
183
|
+
.select_from(Permission)
|
|
184
|
+
.join(PermissionApi)
|
|
185
|
+
.join(PermissionApi.roles)
|
|
186
|
+
.join(Api)
|
|
187
|
+
.where(
|
|
188
|
+
Api.name == api_name,
|
|
189
|
+
Permission.name == permission,
|
|
190
|
+
Role.id.in_(db_role_ids),
|
|
191
|
+
)
|
|
192
|
+
.limit(1)
|
|
193
|
+
)
|
|
194
|
+
result = await smart_run(db.current_session.scalar, stmt)
|
|
195
|
+
result = bool(result)
|
|
196
|
+
if result:
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
raise HTTPException(
|
|
200
|
+
fastapi.status.HTTP_403_FORBIDDEN, ErrorCode.PERMISSION_DENIED
|
|
201
|
+
)
|
|
177
202
|
|
|
178
203
|
return check_permission
|
|
179
204
|
|
|
@@ -208,3 +233,24 @@ async def set_global_request(request: fastapi.Request):
|
|
|
208
233
|
...more code
|
|
209
234
|
"""
|
|
210
235
|
g.request = request
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _ensure_roles(err_forbidden_message=ErrorCode.GET_USER_NO_ROLES):
|
|
239
|
+
"""
|
|
240
|
+
A dependency for FastAPI that will ensure the current user has roles assigned.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
err_forbidden_message (str): The error message to be used when raising `403 Forbidden`. Defaults to `ErrorCode.GET_USER_NO_ROLES`.
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
HTTPException: Raised when the user is not authenticated.
|
|
247
|
+
HTTPException: Raised when the user has no roles assigned.
|
|
248
|
+
"""
|
|
249
|
+
if not g.user:
|
|
250
|
+
raise HTTPException(
|
|
251
|
+
fastapi.status.HTTP_401_UNAUTHORIZED,
|
|
252
|
+
ErrorCode.GET_USER_MISSING_TOKEN_OR_INACTIVE_USER,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if not g.user.roles:
|
|
256
|
+
raise HTTPException(fastapi.status.HTTP_403_FORBIDDEN, err_forbidden_message)
|