fastapi-rtk 0.2.60__py3-none-any.whl → 1.0.13__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.
Files changed (40) hide show
  1. fastapi_rtk/__init__.py +0 -1
  2. fastapi_rtk/_version.py +1 -0
  3. fastapi_rtk/api/model_rest_api.py +182 -87
  4. fastapi_rtk/auth/auth.py +0 -9
  5. fastapi_rtk/backends/sqla/db.py +32 -7
  6. fastapi_rtk/backends/sqla/filters.py +16 -0
  7. fastapi_rtk/backends/sqla/interface.py +11 -62
  8. fastapi_rtk/backends/sqla/model.py +16 -1
  9. fastapi_rtk/bases/db.py +20 -2
  10. fastapi_rtk/bases/file_manager.py +12 -0
  11. fastapi_rtk/bases/filter.py +1 -1
  12. fastapi_rtk/cli/cli.py +61 -0
  13. fastapi_rtk/cli/commands/security.py +6 -6
  14. fastapi_rtk/const.py +1 -1
  15. fastapi_rtk/db.py +3 -0
  16. fastapi_rtk/dependencies.py +110 -64
  17. fastapi_rtk/fastapi_react_toolkit.py +123 -172
  18. fastapi_rtk/file_managers/s3_file_manager.py +63 -32
  19. fastapi_rtk/lang/messages.pot +12 -12
  20. fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.mo +0 -0
  21. fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.po +12 -12
  22. fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.mo +0 -0
  23. fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.po +12 -12
  24. fastapi_rtk/manager.py +2 -0
  25. fastapi_rtk/schemas.py +6 -4
  26. fastapi_rtk/security/sqla/apis.py +20 -5
  27. fastapi_rtk/security/sqla/models.py +8 -23
  28. fastapi_rtk/security/sqla/security_manager.py +367 -10
  29. fastapi_rtk/utils/async_task_runner.py +119 -30
  30. fastapi_rtk/utils/csv_json_converter.py +242 -39
  31. fastapi_rtk/utils/hooks.py +7 -4
  32. fastapi_rtk/utils/self_dependencies.py +1 -1
  33. fastapi_rtk/version.py +6 -1
  34. fastapi_rtk-1.0.13.dist-info/METADATA +28 -0
  35. {fastapi_rtk-0.2.60.dist-info → fastapi_rtk-1.0.13.dist-info}/RECORD +38 -38
  36. {fastapi_rtk-0.2.60.dist-info → fastapi_rtk-1.0.13.dist-info}/WHEEL +1 -2
  37. fastapi_rtk-0.2.60.dist-info/METADATA +0 -25
  38. fastapi_rtk-0.2.60.dist-info/top_level.txt +0 -1
  39. {fastapi_rtk-0.2.60.dist-info → fastapi_rtk-1.0.13.dist-info}/entry_points.txt +0 -0
  40. {fastapi_rtk-0.2.60.dist-info → fastapi_rtk-1.0.13.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 self.is_property(col_name) and not self.is_hybrid_property(col_name):
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 | str]
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: list[FilterSchema] | None
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: list[OprFilterSchema] | None
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:
@@ -51,7 +51,7 @@ class AbstractBaseOprFilter(AbstractBaseFilter[T]):
51
51
  """
52
52
 
53
53
  @abc.abstractmethod
54
- def apply(self, statement, value) -> T:
54
+ def apply(self, statement: T, value) -> T:
55
55
  """
56
56
  Apply the filter to the given SQLAlchemy Select statement.
57
57
 
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.security.create_role(name=name, session=session)
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.security.create_user(
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.security.get_user(email_or_username)
273
- return await g.current_app.security.reset_password(user, password)
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.security.export_data(data, type)
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.security.cleanup()
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 + "/profiles"
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)
@@ -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 permissions(as_object=False):
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
- This will implicitly call the `current_user` dependency from `fastapi_users`. Therefore, it can return `403 Forbidden` if the user is not authenticated.
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
- as_object (bool): Whether to return the `PermissionApi` objects or return the api names (E.g "AuthApi" or "AuthApi|UserApi").
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(permissions()),
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 permissions_depedency():
86
- if not g.user:
87
- raise HTTPException(
88
- fastapi.status.HTTP_401_UNAUTHORIZED,
89
- ErrorCode.GET_USER_MISSING_TOKEN_OR_INACTIVE_USER,
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
- for permission_api in role.permissions:
100
- if as_object:
101
- permissions.append(permission_api)
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
- def current_permissions(api: BaseApi):
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 = [x for x in permissions if x in api.base_permissions]
140
- return list(set(permissions))
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 call the `current_permissions` dependency, it can return `403 Forbidden` if the user is not authenticated.
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
- permissions: list[str] = Depends(current_permissions(api)),
171
- ):
172
- if f"{PERMISSION_PREFIX}{permission}" not in permissions:
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
- return
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)