├── main.py ├── tests ├── __init__.py ├── test_utils │ ├── __init__.py │ └── test_file_upload.py ├── data │ ├── locales │ │ ├── messages.pot │ │ ├── en │ │ │ └── LC_MESSAGES │ │ │ │ ├── messages.po │ │ │ │ └── messages.mo │ │ └── uk │ │ │ └── LC_MESSAGES │ │ │ ├── messages.po │ │ │ └── messages.mo │ └── extra_locales │ │ └── uk │ │ └── LC_MESSAGES │ │ ├── messages.po │ │ └── messages.mo ├── conftest.py └── test_i18n.py ├── examples └── __init__.py ├── fastapi_admin2 ├── ui │ ├── __init__.py │ ├── resources │ │ ├── base.py │ │ ├── link.py │ │ ├── dropdown.py │ │ ├── __init__.py │ │ ├── action.py │ │ └── column.py │ └── widgets │ │ ├── exceptions.py │ │ ├── __init__.py │ │ ├── displays.py │ │ ├── filters.py │ │ └── inputs.py ├── utils │ ├── __init__.py │ ├── files │ │ ├── __init__.py │ │ ├── base.py │ │ ├── utils.py │ │ ├── static.py │ │ ├── s3.py │ │ └── on_premise.py │ ├── depends.py │ ├── forms.py │ ├── responses.py │ └── templating.py ├── backends │ ├── __init__.py │ ├── sqla │ │ ├── dao │ │ │ ├── __init__.py │ │ │ └── admin_dao.py │ │ ├── widgets │ │ │ ├── __init__.py │ │ │ └── inputs.py │ │ ├── markers.py │ │ ├── __init__.py │ │ ├── models.py │ │ ├── queriers.py │ │ ├── field_converters.py │ │ ├── filters.py │ │ ├── model_resource.py │ │ └── toolings.py │ └── tortoise │ │ ├── dao │ │ ├── __init__.py │ │ └── admin_dao.py │ │ ├── widgets │ │ ├── __init__.py │ │ └── inputs.py │ │ ├── models.py │ │ ├── queriers.py │ │ ├── __init__.py │ │ ├── filters.py │ │ ├── field_converters.py │ │ └── model_resource.py ├── middlewares │ ├── __init__.py │ ├── i18n │ │ ├── __init__.py │ │ ├── impl.py │ │ └── base.py │ ├── theme.py │ └── templating.py ├── controllers │ ├── __init__.py │ ├── dependencies.py │ └── resources.py ├── providers │ ├── security │ │ ├── password_hashing │ │ │ ├── __init__.py │ │ │ ├── protocol.py │ │ │ └── argon2_cffi.py │ │ ├── __init__.py │ │ ├── responses.py │ │ ├── dependencies.py │ │ ├── dto.py │ │ └── provider.py │ └── __init__.py ├── i18n │ ├── exceptions.py │ ├── __init__.py │ ├── locales │ │ ├── en │ │ │ └── LC_MESSAGES │ │ │ │ ├── messages.mo │ │ │ │ └── messages.po │ │ ├── ru │ │ │ └── LC_MESSAGES │ │ │ │ ├── messages.mo │ │ │ │ └── messages.po │ │ └── zh │ │ │ └── LC_MESSAGES │ │ │ ├── messages.mo │ │ │ └── messages.po │ ├── utils.py │ ├── lazy_proxy.py │ └── localizer.py ├── templates │ ├── widgets │ │ ├── displays │ │ │ ├── image.html │ │ │ ├── boolean.html │ │ │ └── json.html │ │ ├── filters │ │ │ ├── search.html │ │ │ ├── select.html │ │ │ └── datetime.html │ │ └── inputs │ │ │ ├── input.html │ │ │ ├── color.html │ │ │ ├── textarea.html │ │ │ ├── switch.html │ │ │ ├── image.html │ │ │ ├── radio.html │ │ │ ├── select.html │ │ │ ├── json.html │ │ │ ├── many_to_many.html │ │ │ ├── editor.html │ │ │ └── datetime.html │ ├── components │ │ ├── dropdown-show.html │ │ ├── alert_error.html │ │ ├── link.html │ │ ├── model.html │ │ ├── language.html │ │ ├── mode_switch.html │ │ ├── select.html │ │ └── dropdown.html │ ├── errors │ │ ├── maintenance.html │ │ ├── 403.html │ │ ├── 401.html │ │ ├── 500.html │ │ └── 404.html │ ├── providers │ │ └── login │ │ │ ├── avatar.html │ │ │ ├── renew_password.html │ │ │ └── login.html │ ├── create.html │ ├── update.html │ ├── base.html │ ├── init.html │ └── layout.html ├── __init__.py ├── enums.py ├── entities.py ├── default_settings.py ├── exceptions.py ├── depends.py └── app.py ├── docs └── _static │ ├── example.png │ └── dark_mode.png ├── babel.cfg ├── pyproject.toml ├── README.md └── .gitignore /main.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_admin2/ui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_admin2/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_admin2/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_admin2/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastapi_admin2/middlewares/i18n/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/sqla/dao/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/sqla/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/tortoise/dao/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/tortoise/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastapi_admin2/providers/security/password_hashing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/locales/messages.pot: -------------------------------------------------------------------------------- 1 | msgid "test" 2 | msgstr "" 3 | -------------------------------------------------------------------------------- /tests/data/locales/en/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | msgid "test" 2 | msgstr "" 3 | -------------------------------------------------------------------------------- /tests/data/locales/uk/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | msgid "test" 2 | msgstr "тест" 3 | -------------------------------------------------------------------------------- /tests/data/extra_locales/uk/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | msgid "test" 2 | msgstr "тест" 3 | -------------------------------------------------------------------------------- /fastapi_admin2/ui/resources/base.py: -------------------------------------------------------------------------------- 1 | class Resource: 2 | label: str 3 | icon: str = "" 4 | -------------------------------------------------------------------------------- /fastapi_admin2/ui/widgets/exceptions.py: -------------------------------------------------------------------------------- 1 | class FilterValidationError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | DATA_DIR = Path(__file__).parent / "data" 4 | -------------------------------------------------------------------------------- /docs/_static/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-admin2/HEAD/docs/_static/example.png -------------------------------------------------------------------------------- /fastapi_admin2/i18n/exceptions.py: -------------------------------------------------------------------------------- 1 | class UnableToExtractLocaleFromRequestError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /docs/_static/dark_mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-admin2/HEAD/docs/_static/dark_mode.png -------------------------------------------------------------------------------- /fastapi_admin2/i18n/__init__.py: -------------------------------------------------------------------------------- 1 | from .localizer import Localizer, I18NLocalizer 2 | 3 | __all__ = ('Localizer', 'I18NLocalizer') 4 | -------------------------------------------------------------------------------- /fastapi_admin2/providers/security/__init__.py: -------------------------------------------------------------------------------- 1 | from .provider import SecurityProvider 2 | 3 | __all__ = ('SecurityProvider',) 4 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/displays/image.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [python: fastapi_admin/**.py] 2 | [jinja2: fastapi_admin/templates/**.html] 3 | extensions = jinja2.ext.i18n,jinja2.ext.autoescape 4 | -------------------------------------------------------------------------------- /fastapi_admin2/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import FastAPIAdmin 2 | 3 | __version__ = "0.0.1a1" 4 | 5 | __all__ = ('FastAPIAdmin', '__version__') 6 | -------------------------------------------------------------------------------- /tests/data/locales/en/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-admin2/HEAD/tests/data/locales/en/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /tests/data/locales/uk/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-admin2/HEAD/tests/data/locales/uk/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /tests/data/extra_locales/uk/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-admin2/HEAD/tests/data/extra_locales/uk/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /fastapi_admin2/i18n/locales/en/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-admin2/HEAD/fastapi_admin2/i18n/locales/en/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /fastapi_admin2/i18n/locales/ru/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-admin2/HEAD/fastapi_admin2/i18n/locales/ru/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /fastapi_admin2/i18n/locales/zh/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-admin2/HEAD/fastapi_admin2/i18n/locales/zh/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /fastapi_admin2/ui/resources/link.py: -------------------------------------------------------------------------------- 1 | from fastapi_admin2.ui.resources.base import Resource 2 | 3 | 4 | class Link(Resource): 5 | url: str 6 | target: str = "_self" 7 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/displays/boolean.html: -------------------------------------------------------------------------------- 1 | {% if value %} 2 | true 3 | {% else %} 4 | false 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /fastapi_admin2/ui/resources/dropdown.py: -------------------------------------------------------------------------------- 1 | from typing import List, Type 2 | 3 | from fastapi_admin2.ui.resources.base import Resource 4 | 5 | 6 | class Dropdown(Resource): 7 | resources: List[Type[Resource]] 8 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/sqla/widgets/inputs.py: -------------------------------------------------------------------------------- 1 | from fastapi_admin2.widgets.inputs import BaseForeignKeyInput 2 | 3 | 4 | class ForeignKey(BaseForeignKeyInput): 5 | async def get_options(self): 6 | pass 7 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/filters/search.html: -------------------------------------------------------------------------------- 1 |
2 | {{ label }}: 3 |
4 | 5 |
6 |
7 | -------------------------------------------------------------------------------- /fastapi_admin2/utils/files/__init__.py: -------------------------------------------------------------------------------- 1 | from .on_premise import OnPremiseFileManager 2 | from .base import FileManager 3 | from .static import StaticFilesManager 4 | 5 | __all__ = ( 6 | 'StaticFilesManager', 7 | 'FileManager', 8 | 'OnPremiseFileManager' 9 | ) 10 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/components/dropdown-show.html: -------------------------------------------------------------------------------- 1 | {% for r in resource.resources %} 2 | {% if resource_label == r.label %} 3 | active 4 | {% endif %} 5 | {% else %} 6 | {% if resource.expand %} 7 | show 8 | {% endif %} 9 | {% endfor %} 10 | 11 | 12 | -------------------------------------------------------------------------------- /fastapi_admin2/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class StrEnum(str, Enum): 5 | def __str__(self): 6 | return self.value 7 | 8 | 9 | class HTTPMethod(StrEnum): 10 | GET = "GET" 11 | POST = "POST" 12 | DELETE = "DELETE" 13 | PUT = "PUT" 14 | PATCH = "PATCH" 15 | -------------------------------------------------------------------------------- /fastapi_admin2/entities.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Sequence 3 | 4 | 5 | @dataclass 6 | class AbstractAdmin: 7 | id: int 8 | username: str 9 | password: str 10 | profile_pic: str 11 | 12 | 13 | @dataclass 14 | class ResourceList: 15 | models: Sequence[Any] = () 16 | total_entries_count: int = 0 17 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/displays/json.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
{{ value }}
5 | -------------------------------------------------------------------------------- /fastapi_admin2/utils/files/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import os 3 | from typing import NewType, Union 4 | 5 | from starlette.datastructures import UploadFile 6 | 7 | Link = NewType("Link", str) 8 | 9 | 10 | class FileManager(abc.ABC): 11 | 12 | @abc.abstractmethod 13 | async def download_file(self, file: UploadFile) -> Union[Link, os.PathLike]: 14 | pass 15 | -------------------------------------------------------------------------------- /fastapi_admin2/controllers/dependencies.py: -------------------------------------------------------------------------------- 1 | from fastapi_admin2.entities import ResourceList 2 | from fastapi_admin2.utils.depends import DependencyMarker 3 | 4 | 5 | class ModelListDependencyMarker(DependencyMarker[ResourceList]): 6 | pass 7 | 8 | 9 | class DeleteOneDependencyMarker(DependencyMarker[None]): 10 | pass 11 | 12 | 13 | class DeleteManyDependencyMarker(DependencyMarker[None]): 14 | pass 15 | -------------------------------------------------------------------------------- /fastapi_admin2/utils/files/utils.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from datetime import datetime 3 | 4 | from fastapi import UploadFile 5 | 6 | 7 | def create_unique_file_identifier(file: UploadFile, *parts: str) -> str: 8 | file_extension = pathlib.Path(file.filename).suffix 9 | current_timestamp = str(datetime.now().timestamp()).replace('.', '') 10 | return ''.join(parts) + current_timestamp + file_extension 11 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/tortoise/models.py: -------------------------------------------------------------------------------- 1 | from tortoise import fields, Model 2 | 3 | from fastapi_admin2.entities import AbstractAdmin 4 | 5 | 6 | class AbstractAdminModel(Model, AbstractAdmin): 7 | id = fields.IntField(pk=True) 8 | username = fields.CharField(max_length=50, unique=True) 9 | password = fields.CharField(max_length=200) 10 | profile_pic = fields.CharField(max_length=100) 11 | 12 | class Meta: 13 | abstract = True 14 | -------------------------------------------------------------------------------- /fastapi_admin2/providers/__init__.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from fastapi_admin2.utils.templating import JinjaTemplates 4 | 5 | if typing.TYPE_CHECKING: 6 | from fastapi_admin2.app import FastAPIAdmin 7 | 8 | 9 | class Provider: 10 | name = "provider" 11 | templates: typing.Optional[JinjaTemplates] = None 12 | 13 | def register(self, app: "FastAPIAdmin"): 14 | setattr(app, self.name, self) 15 | 16 | self.templates = app.templates 17 | -------------------------------------------------------------------------------- /fastapi_admin2/ui/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from .action import ToolbarAction, Action 2 | from .dropdown import Dropdown 3 | from .column import Field, ComputedField 4 | from .link import Link 5 | from .model import AbstractModelResource 6 | from .base import Resource 7 | 8 | __all__ = ( 9 | 'ToolbarAction', 10 | 'Action', 11 | 'Resource', 12 | 'Dropdown', 13 | 'Field', 14 | 'ComputedField', 15 | 'Link', 16 | 'AbstractModelResource' 17 | ) 18 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/sqla/markers.py: -------------------------------------------------------------------------------- 1 | from typing import Union, AsyncIterator 2 | 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | from sqlalchemy.orm import sessionmaker 5 | 6 | from fastapi_admin2.utils.depends import DependencyMarker 7 | 8 | 9 | class AsyncSessionDependencyMarker(DependencyMarker[Union[AsyncIterator[AsyncSession], AsyncSession]]): 10 | pass 11 | 12 | 13 | class SessionMakerDependencyMarker(DependencyMarker[sessionmaker]): 14 | pass 15 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/inputs/input.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 5 | {% if help_text %} 6 | 7 | {{ help_text }} 8 | 9 | {% endif %} 10 |
11 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/inputs/color.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 5 | {% if help_text %} 6 | 7 | {{ help_text }} 8 | 9 | {% endif %} 10 |
-------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/inputs/textarea.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 6 | {% if help_text %} 7 | 8 | {{ help_text }} 9 | 10 | {% endif %} 11 |
12 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/components/alert_error.html: -------------------------------------------------------------------------------- 1 | {% if error %} 2 | 13 | {% endif %} 14 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/inputs/switch.html: -------------------------------------------------------------------------------- 1 |
2 |
{{ label }}
3 | 15 |
16 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/inputs/image.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | {% if value %} 4 |
5 | {% endif %} 6 | 8 | {% if help_text %} 9 | 10 | {{ help_text }} 11 | 12 | {% endif %} 13 |
14 | -------------------------------------------------------------------------------- /fastapi_admin2/i18n/utils.py: -------------------------------------------------------------------------------- 1 | from starlette.requests import Request 2 | 3 | from fastapi_admin2.i18n.exceptions import UnableToExtractLocaleFromRequestError 4 | 5 | 6 | def get_locale_from_request(request: Request) -> str: 7 | if locale := request.query_params.get("language"): 8 | return locale 9 | if locale := request.cookies.get("language"): 10 | return locale 11 | 12 | if accept_language := request.headers.get("Accept-Language"): 13 | return accept_language.split(",")[0].replace("-", "_") 14 | 15 | raise UnableToExtractLocaleFromRequestError() 16 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/components/link.html: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /fastapi_admin2/i18n/lazy_proxy.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi_admin2.exceptions import RequiredThirdPartyLibNotInstalled 4 | 5 | try: 6 | from babel.support import LazyProxy 7 | except ImportError: # pragma: no cover 8 | 9 | class LazyProxy: # type: ignore 10 | def __init__(self, func: Any, *args: Any, **kwargs: Any) -> None: 11 | raise RequiredThirdPartyLibNotInstalled( 12 | lib_name="Babel", 13 | thing_that_cant_work_without_lib="LazyProxy", 14 | can_be_installed_with_ext="i18n" 15 | ) 16 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/components/model.html: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /fastapi_admin2/providers/security/responses.py: -------------------------------------------------------------------------------- 1 | from starlette.requests import Request 2 | from starlette.responses import RedirectResponse 3 | from starlette.status import HTTP_303_SEE_OTHER 4 | 5 | 6 | def to_init_page(request: Request) -> RedirectResponse: 7 | return RedirectResponse( 8 | url=request.app.admin_path + "/init", 9 | status_code=HTTP_303_SEE_OTHER 10 | ) 11 | 12 | 13 | def to_login_page(request: Request) -> RedirectResponse: 14 | return RedirectResponse( 15 | request.app.admin_path + "/login", 16 | status_code=HTTP_303_SEE_OTHER 17 | ) 18 | -------------------------------------------------------------------------------- /fastapi_admin2/providers/security/password_hashing/protocol.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Protocol, Union 2 | 3 | 4 | class HashVerifyFailedError(Exception): 5 | 6 | def __init__(self, orig: Optional[Exception] = None): 7 | self.orig = orig 8 | 9 | 10 | class PasswordHasherProto(Protocol): 11 | 12 | def is_rehashing_required(self, hash_: str) -> bool: ... 13 | 14 | def verify(self, hash_: str, password: str) -> None: 15 | """ 16 | Verifies hash and plain text password. 17 | It raises exception(HashingFailedError) if something fail. 18 | """ 19 | 20 | def hash(self, password: Union[str, bytes]) -> str: ... -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/filters/select.html: -------------------------------------------------------------------------------- 1 | {% with id = 'form-select-' + name %} 2 |
3 | {{ label }}: 4 |
5 | 12 |
13 |
14 | {% include "components/select.html" %} 15 | {% endwith %} 16 | -------------------------------------------------------------------------------- /fastapi_admin2/default_settings.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | BASE_DIR = pathlib.Path(__file__).resolve().parent 4 | # time format 5 | DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" 6 | DATE_FORMAT = "%Y-%m-%d" 7 | 8 | # MOMENT - js library for filter datetimes range 9 | DATETIME_FORMAT_MOMENT = "YYYY-MM-DD HH:mm:ss" 10 | DATE_FORMAT_MOMENT = "YYYY-MM-DD" 11 | 12 | # flatpickr settings - js library for input datetime 13 | DATE_FORMAT_FLATPICKR = "Y-m-d" 14 | 15 | # redis cache 16 | CAPTCHA_ID = "captcha:{captcha_id}" 17 | LOGIN_ERROR_TIMES = "login_error_times:{ip}" 18 | LOGIN_USER = "login_user:{session_id}" 19 | 20 | # i18n 21 | PATH_TO_LOCALES = BASE_DIR / "i18n" / "locales" 22 | -------------------------------------------------------------------------------- /fastapi_admin2/ui/resources/action.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Any, Dict 2 | 3 | from pydantic import BaseModel, validator 4 | 5 | from fastapi_admin2.enums import HTTPMethod 6 | 7 | 8 | class Action(BaseModel): 9 | icon: str 10 | label: str 11 | name: str 12 | method: HTTPMethod = HTTPMethod.POST 13 | ajax: bool = True 14 | 15 | @validator("ajax") 16 | def validate_ajax(cls, v: bool, values: Dict[str, Any], **kwargs: Any): 17 | if not v and values["method"] != HTTPMethod.GET: 18 | raise ValueError("ajax is False only available when method is Method.GET") 19 | 20 | 21 | class ToolbarAction(Action): 22 | class_: Optional[str] 23 | -------------------------------------------------------------------------------- /fastapi_admin2/utils/files/static.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Union 3 | 4 | from starlette.datastructures import UploadFile 5 | 6 | from fastapi_admin2.utils.files.base import FileManager, Link 7 | 8 | 9 | class StaticFilesManager(FileManager): 10 | 11 | def __init__(self, file_uploader: FileManager, static_path_prefix: str = "/static/uploads"): 12 | self._file_uploader = file_uploader 13 | self._static_path_prefix = static_path_prefix 14 | 15 | async def download_file(self, file: UploadFile) -> Union[Link, os.PathLike]: 16 | await self._file_uploader.download_file(file) 17 | return Link(os.path.join(self._static_path_prefix, file.filename)) -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/inputs/radio.html: -------------------------------------------------------------------------------- 1 |
2 |
{{ label }}
3 | {% for option in options %} 4 | 10 | {% endfor %} 11 | {% if help_text %} 12 | 13 | {{ help_text }} 14 | 15 | {% endif %} 16 |
17 | -------------------------------------------------------------------------------- /fastapi_admin2/middlewares/theme.py: -------------------------------------------------------------------------------- 1 | from starlette.middleware.base import RequestResponseEndpoint 2 | from starlette.requests import Request 3 | from starlette.responses import Response 4 | 5 | 6 | async def theme_middleware(request: Request, call_next: RequestResponseEndpoint) -> Response: 7 | dark_mode_toggled = request.query_params.get('theme') == 'dark' 8 | light_theme_toggled = request.query_params.get('theme') == 'light' 9 | response = await call_next(request) 10 | 11 | if dark_mode_toggled: 12 | response.set_cookie('dark_mode', 'yes', path=request.app.admin_path) 13 | elif light_theme_toggled: 14 | response.delete_cookie('dark_mode', path=request.app.admin_path) 15 | 16 | return response 17 | -------------------------------------------------------------------------------- /fastapi_admin2/providers/security/dependencies.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol, Any, Dict 2 | 3 | from fastapi_admin2.entities import AbstractAdmin 4 | from fastapi_admin2.exceptions import DatabaseError 5 | from fastapi_admin2.utils.depends import DependencyMarker 6 | 7 | 8 | class AdminDaoProto(Protocol): 9 | 10 | async def get_one_admin_by_filters(self, **filters: Any) -> AbstractAdmin: ... 11 | 12 | async def is_exists_at_least_one_admin(self, **filters: Any) -> bool: ... 13 | 14 | async def add_admin(self, **values: Any) -> None: ... 15 | 16 | async def update_admin(self, filters: Dict[Any, Any], **values: Any) -> None: ... 17 | 18 | 19 | class EntityNotFound(DatabaseError): 20 | pass 21 | 22 | 23 | class AdminDaoDependencyMarker(DependencyMarker[AdminDaoProto]): 24 | pass 25 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/errors/maintenance.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block body %} 3 |
4 |
5 |
6 |
8 |
9 |

Temporarily down for maintenance

10 |

11 | Sorry for the inconvenience but we’re performing some maintenance at the moment. We’ll be back 12 | online shortly! 13 |

14 |
15 |
16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/inputs/select.html: -------------------------------------------------------------------------------- 1 | {% include "components/select.html" %} 2 | {% with id = 'form-select-' + name %} 3 |
4 |
{{ label }}
5 | 12 | {% if help_text %} 13 |
14 | 15 | {{ help_text }} 16 | 17 |
18 | {% endif %} 19 |
20 | {% endwith %} 21 | -------------------------------------------------------------------------------- /fastapi_admin2/providers/security/dto.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi import UploadFile 4 | from pydantic import BaseModel, validator 5 | 6 | from fastapi_admin2.utils.forms import as_form 7 | 8 | 9 | @as_form 10 | class InitAdmin(BaseModel): 11 | username: str 12 | password: str 13 | confirm_password: str 14 | profile_pic: UploadFile 15 | 16 | 17 | @as_form 18 | class RenewPasswordCredentials(BaseModel): 19 | old_password: str 20 | new_password: str 21 | confirmation_new_password: str 22 | 23 | 24 | @as_form 25 | class LoginCredentials(BaseModel): 26 | username: str 27 | password: str 28 | remember_me: bool 29 | 30 | @validator("remember_me", pre=True) 31 | def covert_remember_me_to_bool(cls, v: Optional[str]) -> bool: 32 | if v == "on": 33 | return True 34 | return False 35 | -------------------------------------------------------------------------------- /fastapi_admin2/ui/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | from starlette.requests import Request 4 | 5 | 6 | class Widget: 7 | template_name = "" 8 | 9 | def __init__(self, **context: Any): 10 | """ 11 | All context will pass to template render if template is not empty. 12 | 13 | :param context: 14 | """ 15 | self.context = context 16 | 17 | async def render(self, request: Request, value: Any) -> str: 18 | if value is None: 19 | value = "" 20 | if not self.template_name: 21 | return value 22 | return await request.state.render_jinja( 23 | self.template_name, 24 | context=dict( 25 | value=value, 26 | current_locale=request.state.current_locale, 27 | **self.context 28 | ) 29 | ) 30 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/providers/login/avatar.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastapi-admin2" 3 | version = "0.0.1a1" 4 | description = "" 5 | authors = ["Glib Garanin aka GLEF1X"] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.8" 10 | babel = "^2.9.1" 11 | aioredis = "^2.0.1" 12 | fastapi = "^0.79.1" 13 | jinja2 = "3.1.2" 14 | python-multipart = "0.0.5" 15 | orjson = { optional = true, version = "^3.7.12"} 16 | argon2-cffi = { optional = true, version = "^21.3.0"} 17 | 18 | [tool.poetry.dev-dependencies] 19 | # lint 20 | black = "22.6.0" 21 | flake8 = "5.0.4" 22 | isort = "5.10.1" 23 | mypy = "0.971" 24 | 25 | # tests 26 | pytest = "^7.1.1" 27 | pytest-asyncio = "^0.19.0" 28 | 29 | # orm dialects 30 | tortoise-orm = "0.19.2" 31 | SQLAlchemy = "^1.4.34" 32 | 33 | # stubs 34 | sqlalchemy2-stubs = "0.0.2a25" 35 | types-aiofiles = "*" 36 | 37 | # subdeps of orm dialects 38 | asyncpg = "0.26" 39 | 40 | [build-system] 41 | requires = ["poetry-core>=1.0.0"] 42 | build-backend = "poetry.core.masonry.api" 43 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/tortoise/dao/admin_dao.py: -------------------------------------------------------------------------------- 1 | from typing import Type, Any, Dict 2 | 3 | from fastapi_admin2.backends.tortoise.models import AbstractAdminModel 4 | from fastapi_admin2.providers.security.dependencies import AdminDaoProto 5 | 6 | 7 | class TortoiseAdminDao(AdminDaoProto): 8 | 9 | def __init__(self, admin_model: Type[AbstractAdminModel]): 10 | self._admin_model = admin_model 11 | 12 | async def get_one_admin_by_filters(self, **filters: Any) -> AbstractAdminModel: 13 | return await self._admin_model.filter(**filters).first() 14 | 15 | async def is_exists_at_least_one_admin(self, **filters: Any) -> bool: 16 | return await self._admin_model.filter(**filters).exists() 17 | 18 | async def add_admin(self, **values: Any) -> None: 19 | await self._admin_model.create(**values) 20 | 21 | async def update_admin(self, filters: Dict[Any, Any], **values: Any) -> None: 22 | await self._admin_model.filter(**filters).update(**values) 23 | -------------------------------------------------------------------------------- /fastapi_admin2/middlewares/templating.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Awaitable 2 | 3 | from starlette.middleware.base import RequestResponseEndpoint 4 | from starlette.requests import Request 5 | from starlette.responses import Response 6 | 7 | from fastapi_admin2.utils.templating import JinjaTemplates, supplement_template_name 8 | 9 | 10 | def create_template_middleware(templates: JinjaTemplates) -> Callable[ 11 | [Request, RequestResponseEndpoint], Awaitable[Response] 12 | ]: 13 | async def add_render_function_to_request(request: Request, call_next: RequestResponseEndpoint) -> Response: 14 | request.state.create_html_response = templates.create_html_response 15 | 16 | async def render_jinja_template(template_name, context): 17 | template = templates.env.get_template(supplement_template_name(template_name)) 18 | return await template.render_async(context) 19 | 20 | request.state.render_jinja = render_jinja_template 21 | return await call_next(request) 22 | 23 | return add_render_function_to_request 24 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/create.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block page_body %} 3 |
4 |
5 |
6 |

{{ resource_label }}

7 |
8 |
9 |
10 | {% for input in inputs %} 11 | {{ input|safe }} 12 | {% endfor %} 13 | 20 |
21 |
22 |
23 |
24 | {% endblock %} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI2 Admin 2 | 3 | ### Introduction 4 | `fastapi-admin2` is an upgraded [fastapi-admin](https://github.com/fastapi-admin/fastapi-admin), that 5 | supports ORM dialects, true Dependency Injection and extendability. Now it's not a production-ready library, 6 | but it will become it. 7 | 8 | ### Key Features 9 | 10 | * Entirely asynchronous(built on FastAPI) 11 | * Supports a bunch of ORM dialects(currently sqlalchemy and tortoise) 12 | * Thoughtful internationalization 13 | * Dark mode 14 | * Extendable and fully customizable 15 | * Built-in authentication, authorization and AWS S3 integration 16 | * High code quality 17 | 18 | 19 | ### TODO list 20 | * Implement more dialects(Mongo, peewee) 21 | * Implement auto-resolving relationships, foreign keys for `sqlalchemy` 22 | * Write tests to achieve 100% code coverage 23 | * Keyset pagination 24 | 25 | ## What does it look like? 26 | 27 | ![](https://github.com/GLEF1X/fastapi-admin2/blob/master/docs/_static/example.png?raw=true) 28 | 29 | 30 | ![](https://github.com/GLEF1X/fastapi-admin2/blob/master/docs/_static/dark_mode.png?raw=true) 31 | 32 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/components/language.html: -------------------------------------------------------------------------------- 1 | 25 | -------------------------------------------------------------------------------- /fastapi_admin2/ui/resources/column.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Mapping, Any 2 | 3 | from starlette.requests import Request 4 | 5 | from fastapi_admin2.ui.widgets import displays, inputs 6 | from fastapi_admin2.ui.widgets.inputs import Input 7 | 8 | 9 | class Field: 10 | name: str 11 | label: str 12 | display: displays.Display 13 | input: inputs.Input 14 | 15 | def __init__( 16 | self, 17 | name: str, 18 | label: Optional[str] = None, 19 | display: Optional[displays.Display] = None, 20 | input_: Optional[Input] = None, 21 | ): 22 | self.name = name 23 | self.label = label or name.title() 24 | 25 | if not display: 26 | display = displays.Display() 27 | display.context.update(label=self.label) 28 | self.display = display 29 | 30 | if not input_: 31 | input_ = inputs.Input() 32 | input_.context.update(label=self.label, name=name) 33 | self.input = input_ 34 | 35 | 36 | class ComputedField(Field): 37 | async def get_value(self, request: Request, obj: Mapping[str, Any]) -> Optional[Any]: 38 | return obj.get(self.name) 39 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/components/mode_switch.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /fastapi_admin2/utils/depends.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Generic, TypeVar, Type, TYPE_CHECKING 4 | 5 | from starlette.requests import Request 6 | from starlette.routing import Mount 7 | 8 | if TYPE_CHECKING: 9 | from fastapi_admin2.app import FastAPIAdmin 10 | 11 | T = TypeVar("T") 12 | 13 | 14 | class DependencyResolvingError(Exception): 15 | pass 16 | 17 | 18 | class DependencyMarker(Generic[T]): 19 | pass 20 | 21 | 22 | def get_dependency_from_request_by_marker(request: Request, marker: Type[DependencyMarker[T]]) -> T: 23 | fastapi_admin_instance = get_fastapi_admin_instance_from_request(request) 24 | return fastapi_admin_instance.dependency_overrides[marker]() 25 | 26 | 27 | def get_fastapi_admin_instance_from_request(request: Request) -> FastAPIAdmin: 28 | from fastapi_admin2.app import FastAPIAdmin 29 | 30 | if isinstance(request.app, FastAPIAdmin): 31 | return request.app 32 | 33 | if not isinstance(request.app, FastAPIAdmin): 34 | for route in request.app.router.routes: 35 | if not isinstance(route, Mount): 36 | continue 37 | if not isinstance(route.app, FastAPIAdmin): 38 | continue 39 | return route.app 40 | 41 | raise DependencyResolvingError("FastAPI admin instance not found in request") 42 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/update.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} {% block page_body %} 2 |
3 |
4 |
5 |

{{ resource_label }}

6 |
7 |
8 |
13 | {% for input in inputs %} {{ input|safe }} {% endfor %} 14 | 32 |
33 |
34 |
35 |
36 | {% endblock %} -------------------------------------------------------------------------------- /fastapi_admin2/templates/errors/403.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block body %} 3 |
4 |
5 |
6 |
403
7 |

Oops… You are forbidden

8 |

9 | We are sorry but the page you are looking for was forbidden 10 |

11 | 24 |
25 |
26 |
27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/errors/401.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block body %} 3 |
4 |
5 |
6 |
401
7 |

Oops… You are unauthorized

8 |

9 | We are sorry but the page you are looking for need authorization. 10 |

11 | 24 |
25 |
26 |
27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/errors/500.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block body %} 3 |
4 |
5 |
6 |
500
7 |

Oops… You just found an error page

8 |

9 | We are sorry but our server encountered an internal error 10 |

11 | 24 |
25 |
26 |
27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/errors/404.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block body %} 3 |
4 |
5 |
6 |
404
7 |

Oops… You just found an error page

8 |

9 | We are sorry but the page you are looking for was not found 10 |

11 | 24 |
25 |
26 |
27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/tortoise/widgets/inputs.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | from starlette.requests import Request 5 | 6 | from fastapi_admin2.widgets.inputs import Input, BaseManyToManyInput, BaseForeignKeyInput 7 | 8 | 9 | class ForeignKey(BaseForeignKeyInput): 10 | async def get_options(self): 11 | ret = await self.get_queryset() 12 | options = [(str(x), x.pk) for x in ret] 13 | if self.context.get("null"): 14 | options = [("", "")] + options 15 | return options 16 | 17 | async def get_queryset(self): 18 | return await self.model.all() 19 | 20 | 21 | class ManyToMany(BaseManyToManyInput): 22 | template_name = "widgets/inputs/many_to_many.html" 23 | 24 | async def render(self, request: Request, value: Any): 25 | options = await self.get_options() 26 | selected = list(map(lambda x: x.pk, value.related_objects if value else [])) 27 | for option in options: 28 | if option.get("value") in selected: 29 | option["selected"] = True 30 | self.context.update(options=json.dumps(options)) 31 | return await super(Input, self).render(request, value) 32 | 33 | async def get_options(self): 34 | ret = await self.get_queryset() 35 | options = [dict(label=str(x), value=x.pk) for x in ret] 36 | return options 37 | 38 | async def get_queryset(self): 39 | return await self.model.all() 40 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/components/select.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/tortoise/queriers.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import Depends 4 | from starlette.requests import Request 5 | from tortoise import Model 6 | 7 | from fastapi_admin2.entities import ResourceList 8 | from fastapi_admin2.depends import get_model_resource, get_orm_model_by_resource_name 9 | from fastapi_admin2.resources import AbstractModelResource 10 | 11 | 12 | async def get_resource_list(request: Request, 13 | model_resource: AbstractModelResource = Depends(get_model_resource), 14 | page_size: int = 10, 15 | model: Model = Depends(get_orm_model_by_resource_name), 16 | page_num: int = 1) -> ResourceList: 17 | qs = model.all() 18 | qs = await model_resource.enrich_select_with_filters(request, model, query=qs) 19 | 20 | total = await qs.count() 21 | 22 | if page_size: 23 | qs = qs.limit(page_size) 24 | else: 25 | page_size = model_resource.page_size 26 | 27 | qs = qs.offset((page_num - 1) * page_size) 28 | 29 | return ResourceList(models=await qs, total_entries_count=total) 30 | 31 | 32 | async def delete_one_by_id(id_: Any, model: Model = Depends(get_orm_model_by_resource_name)) -> None: 33 | await model.filter(pk=id_).delete() 34 | 35 | 36 | async def bulk_delete_resources(ids: str, model: Model = Depends(get_orm_model_by_resource_name)) -> None: 37 | await model.filter(pk__in=ids.split(",")).delete() 38 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/inputs/json.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
{{ label }}
5 |
6 | {% if help_text %} 7 | 8 | {{ help_text }} 9 | 10 | {% endif %} 11 | 12 | 25 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/tortoise/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Type 2 | 3 | from fastapi import FastAPI 4 | from tortoise.contrib.fastapi import register_tortoise 5 | 6 | from fastapi_admin2.backends.tortoise.dao.admin_dao import TortoiseAdminDao 7 | from fastapi_admin2.backends.tortoise.models import AbstractAdminModel 8 | from fastapi_admin2.backends.tortoise.models import Model 9 | from fastapi_admin2.backends.tortoise.queriers import get_resource_list, delete_one_by_id, \ 10 | bulk_delete_resources 11 | from fastapi_admin2.providers.security.dependencies import AdminDaoDependencyMarker 12 | from fastapi_admin2.controllers.dependencies import ModelListDependencyMarker, DeleteOneDependencyMarker, \ 13 | DeleteManyDependencyMarker 14 | 15 | 16 | class TortoiseBackend: 17 | 18 | def __init__(self, registration_config: Dict[str, Any], admin_model_cls: Type[AbstractAdminModel]): 19 | self._admin_model_cls = admin_model_cls 20 | self._config = registration_config 21 | 22 | def configure(self, app: FastAPI) -> None: 23 | register_tortoise(app, **self._config) 24 | 25 | app.dependency_overrides[AdminDaoDependencyMarker] = lambda: TortoiseAdminDao(self._admin_model_cls) 26 | 27 | app.dependency_overrides[ModelListDependencyMarker] = get_resource_list 28 | app.dependency_overrides[DeleteOneDependencyMarker] = delete_one_by_id 29 | app.dependency_overrides[DeleteManyDependencyMarker] = bulk_delete_resources 30 | 31 | 32 | __all__ = ('TortoiseBackend', 'Model') 33 | -------------------------------------------------------------------------------- /fastapi_admin2/providers/security/password_hashing/argon2_cffi.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from argon2 import PasswordHasher, DEFAULT_TIME_COST, DEFAULT_MEMORY_COST, DEFAULT_PARALLELISM, \ 4 | DEFAULT_HASH_LENGTH, DEFAULT_RANDOM_SALT_LENGTH, Type 5 | from argon2.exceptions import InvalidHash, VerifyMismatchError, VerificationError 6 | 7 | from fastapi_admin2.providers.security.password_hashing.protocol import PasswordHasherProto, \ 8 | HashVerifyFailedError 9 | 10 | 11 | class Argon2PasswordHasher(PasswordHasherProto): 12 | 13 | def __init__( 14 | self, 15 | time_cost: int = DEFAULT_TIME_COST, 16 | memory_cost: int = DEFAULT_MEMORY_COST, 17 | parallelism: int = DEFAULT_PARALLELISM, 18 | hash_len: int = DEFAULT_HASH_LENGTH, 19 | salt_len: int = DEFAULT_RANDOM_SALT_LENGTH, 20 | encoding: str = "utf-8", 21 | type_: Type = Type.ID, 22 | ): 23 | self._hasher = PasswordHasher(time_cost, memory_cost, parallelism, hash_len, salt_len, encoding, type_) 24 | 25 | def is_rehashing_required(self, hash_: str) -> bool: 26 | return self._hasher.check_needs_rehash(hash_) 27 | 28 | def verify(self, hash_: str, password: str) -> None: 29 | try: 30 | self._hasher.verify(hash_, password) 31 | except (InvalidHash, VerifyMismatchError, VerificationError) as ex: 32 | raise HashVerifyFailedError(ex) 33 | 34 | def hash(self, password: Union[str, bytes]) -> str: 35 | return self._hasher.hash(password) 36 | -------------------------------------------------------------------------------- /fastapi_admin2/utils/forms.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Type, cast, Optional, Union, Callable 3 | 4 | from fastapi import Form 5 | from pydantic import BaseModel 6 | 7 | 8 | class FormBaseModel(BaseModel): 9 | 10 | @classmethod 11 | def as_form(cls) -> BaseModel: 12 | """Generated in as_form function""" 13 | 14 | 15 | def as_form(maybe_cls: Optional[Type[BaseModel]] = None) -> Union[ 16 | Type[FormBaseModel], Callable[[Type[BaseModel]], Type[FormBaseModel]] 17 | ]: 18 | def wrap(cls): 19 | return _transform_to_form(cls) 20 | 21 | if maybe_cls is None: 22 | return wrap 23 | 24 | return _transform_to_form(maybe_cls) 25 | 26 | 27 | def _transform_to_form(cls: Type[BaseModel]) -> Type[FormBaseModel]: 28 | new_parameters = [] 29 | 30 | for field_name, model_field in cls.__fields__.items(): 31 | model_field: ModelField # type: ignore 32 | 33 | new_parameters.append( 34 | inspect.Parameter( 35 | model_field.alias, 36 | inspect.Parameter.POSITIONAL_ONLY, 37 | default=Form(...) if not model_field.required else Form(model_field.default), 38 | annotation=model_field.outer_type_, 39 | ) 40 | ) 41 | 42 | async def as_form_func(**data): 43 | return cls(**data) 44 | 45 | sig = inspect.signature(as_form_func) 46 | sig = sig.replace(parameters=new_parameters) 47 | as_form_func.__signature__ = sig # type: ignore 48 | setattr(cls, 'as_form', as_form_func) 49 | return cast(Type[FormBaseModel], cls) 50 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% if request.app.favicon_url %} 12 | 13 | {% endif %} 14 | 15 | 16 | 17 | 18 | 19 | {% block head %} 20 | {% endblock %} 21 | {{ title }} 22 | 23 | {% block outer_body %} 24 | 25 | {% block body %} 26 | {% endblock %} 27 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /fastapi_admin2/utils/responses.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | from starlette.requests import Request 3 | from starlette.responses import RedirectResponse, Response, HTMLResponse 4 | from starlette.status import HTTP_303_SEE_OTHER, HTTP_500_INTERNAL_SERVER_ERROR 5 | 6 | 7 | def redirect(request: Request, view: str, **params) -> Response: 8 | return RedirectResponse( 9 | url=request.app.admin_path + request.app.url_path_for(view, **params), 10 | status_code=HTTP_303_SEE_OTHER, 11 | ) 12 | 13 | 14 | async def server_error_exception( 15 | request: Request, 16 | exc: HTTPException, 17 | ) -> HTMLResponse: 18 | return await request.state.create_html_response( 19 | "errors/500.html", 20 | status_code=HTTP_500_INTERNAL_SERVER_ERROR, 21 | context={"request": request, "exc": exc}, 22 | ) 23 | 24 | 25 | async def not_found( 26 | request: Request, 27 | exc: HTTPException, 28 | ) -> HTMLResponse: 29 | return await request.state.create_html_response( 30 | "errors/404.html", status_code=exc.status_code, context={"request": request} 31 | ) 32 | 33 | 34 | async def forbidden( 35 | request: Request, 36 | exc: HTTPException, 37 | ) -> HTMLResponse: 38 | return await request.state.create_html_response( 39 | "errors/403.html", status_code=exc.status_code, context={"request": request} 40 | ) 41 | 42 | 43 | async def unauthorized( 44 | request: Request, 45 | exc: HTTPException, 46 | ) -> HTMLResponse: 47 | return await request.state.create_html_response( 48 | "errors/401.html", status_code=exc.status_code, context={"request": request} 49 | ) 50 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/inputs/many_to_many.html: -------------------------------------------------------------------------------- 1 | {% with id = 'form-select-' + name %} 2 |
3 |
{{ label }}
4 | 6 | {% if help_text %} 7 | 8 | {{ help_text }} 9 | 10 | {% endif %} 11 |
12 | 39 | {% endwith %} 40 | -------------------------------------------------------------------------------- /fastapi_admin2/utils/files/s3.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional, Union 3 | 4 | from aioboto3 import Session 5 | from aiobotocore.client import AioBaseClient 6 | from fastapi import UploadFile 7 | 8 | from fastapi_admin2.utils.files import FileManager 9 | from fastapi_admin2.utils.files.base import Link 10 | from fastapi_admin2.utils.files.utils import create_unique_file_identifier 11 | 12 | 13 | class S3FileManager(FileManager): 14 | def __init__(self, bucket_name: str, access_key: str, secret_key: str, region: str, 15 | file_identifier_prefix: str) -> None: 16 | self._client: Optional[AioBaseClient] = None 17 | self._bucket_name = bucket_name 18 | self._access_key = access_key 19 | self._secret_key = secret_key 20 | self._region_name = region 21 | self._file_identifier_prefix = file_identifier_prefix 22 | 23 | async def download_file(self, file: UploadFile) -> Union[Link, os.PathLike]: 24 | file_identifier = create_unique_file_identifier(file, self._file_identifier_prefix) 25 | 26 | async with await self.connect() as s3: # type: AioBaseClient 27 | await s3.upload_fileobj(file.file, self._bucket_name, file_identifier) 28 | 29 | return Link("https://{0}.s3.{1}.amazonaws.com/{2}".format( 30 | self._bucket_name, self._region_name, file_identifier 31 | )) 32 | 33 | async def connect(self) -> AioBaseClient: 34 | if self._client is None: 35 | session = Session( 36 | aws_access_key_id=self._access_key, 37 | aws_secret_access_key=self._secret_key, 38 | region_name=self._region_name 39 | ) 40 | self._client = session.client("s3") 41 | 42 | return self._client 43 | -------------------------------------------------------------------------------- /fastapi_admin2/middlewares/i18n/impl.py: -------------------------------------------------------------------------------- 1 | from typing import cast, Optional 2 | 3 | from starlette.requests import Request 4 | from starlette.types import ASGIApp 5 | 6 | from fastapi_admin2.exceptions import RequiredThirdPartyLibNotInstalled 7 | from fastapi_admin2.i18n.exceptions import UnableToExtractLocaleFromRequestError 8 | from fastapi_admin2.i18n.localizer import Localizer 9 | from fastapi_admin2.i18n.utils import get_locale_from_request 10 | from fastapi_admin2.middlewares.i18n.base import AbstractI18nMiddleware 11 | 12 | try: 13 | from babel import Locale 14 | except ImportError: # pragma: no cover 15 | Locale = None 16 | 17 | 18 | class I18nMiddleware(AbstractI18nMiddleware): 19 | """ 20 | Simple I18n middleware. 21 | Chooses language code from the request 22 | """ 23 | 24 | def __init__(self, app: ASGIApp, 25 | translator: Optional[Localizer] = None, ) -> None: 26 | super().__init__(app, translator) 27 | _raise_if_babel_not_installed() 28 | 29 | async def get_locale(self, request: Request) -> str: 30 | 31 | try: 32 | locale = get_locale_from_request(request) 33 | except UnableToExtractLocaleFromRequestError: 34 | return self._translator.default_locale 35 | 36 | parsed_locale = Locale.parse(locale) 37 | if parsed_locale.language not in self._translator.available_translations: 38 | return self._translator.default_locale 39 | return cast(str, parsed_locale.language) 40 | 41 | 42 | def _raise_if_babel_not_installed() -> None: 43 | if Locale is not None: # pragma: no cover 44 | return 45 | 46 | raise RequiredThirdPartyLibNotInstalled( 47 | "Babel", 48 | thing_that_cant_work_without_lib="i18n", 49 | can_be_installed_with_ext="i18n" 50 | ) 51 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/tortoise/filters.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from fastapi_admin2.widgets.filters import BaseSearchFilter, BaseDateRangeFilter, \ 4 | BaseEnumFilter, BaseBooleanFilter, Q, BaseDateTimeRangeFilter 5 | 6 | SearchMode = Literal[ 7 | "equal", "contains", "icontains", "startswith", 8 | "istartswith", "endswith", "iendswith", "iexact", "search" 9 | ] 10 | 11 | 12 | class Search(BaseSearchFilter): 13 | 14 | def __init__( 15 | self, 16 | name: str, 17 | search_mode: SearchMode = "equal", 18 | placeholder: str = "", 19 | null: bool = True, 20 | **additional_context: Any 21 | ) -> None: 22 | super().__init__(name, placeholder, null, **additional_context) 23 | self._search_mode = search_mode 24 | 25 | def _apply_to_sql_query(self, query: Q, value: Any) -> Q: 26 | return query.filter(**{self.name: f"{self.name}__{self._search_mode}"}) 27 | 28 | 29 | class DateRange(BaseDateRangeFilter): 30 | def _apply_to_sql_query(self, query: Q, value: Any) -> Q: 31 | return query.filter(**{self.name: f"{self.name}__range"}) 32 | 33 | 34 | class DateTimeRange(BaseDateTimeRangeFilter): 35 | def _apply_to_sql_query(self, query: Q, value: Any) -> Q: 36 | return query.filter(**{self.name: f"{self.name}__range"}) 37 | 38 | 39 | class Enum(BaseEnumFilter): 40 | def _apply_to_sql_query(self, query: Q, value: Any) -> Q: 41 | return query.filter(**{self.name: value}) 42 | 43 | 44 | class Boolean(BaseBooleanFilter): 45 | 46 | def clean(self, value: Any) -> Any: 47 | if value == "true": 48 | return True 49 | return False 50 | 51 | def _apply_to_sql_query(self, query: Q, value: Any) -> Q: 52 | return query.filter(**{self.name: value}) 53 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/providers/login/renew_password.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block page_body %} 3 | {% include "components/alert_error.html" %} 4 |
5 |
6 |

{{ _('update_password') }}

7 |
8 |
9 |
10 |
11 | 14 | 16 |
17 |
18 | 21 | 23 |
24 |
25 | 28 | 30 |
31 | 34 |
35 |
36 |
37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /fastapi_admin2/ui/widgets/displays.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | from typing import Optional, Any, Callable 4 | 5 | from starlette.requests import Request 6 | 7 | from fastapi_admin2.default_settings import DATETIME_FORMAT, DATE_FORMAT 8 | from fastapi_admin2.ui.widgets import Widget 9 | 10 | 11 | class Display(Widget): 12 | """ 13 | Parent class for all display widgets 14 | """ 15 | 16 | 17 | class DatetimeDisplay(Display): 18 | def __init__(self, format_: str = DATETIME_FORMAT): 19 | super().__init__() 20 | self.format_ = format_ 21 | 22 | async def render(self, request: Request, value: datetime) -> str: 23 | timestamp = value 24 | if value is not None: 25 | timestamp = value.strftime(self.format_) 26 | 27 | return await super(DatetimeDisplay, self).render(request, timestamp) 28 | 29 | 30 | class DateDisplay(DatetimeDisplay): 31 | def __init__(self, format_: str = DATE_FORMAT): 32 | super().__init__(format_) 33 | 34 | 35 | class InputOnly(Display): 36 | """ 37 | Only input without showing in display 38 | """ 39 | 40 | 41 | class Boolean(Display): 42 | template_name = "widgets/displays/boolean.html" 43 | 44 | 45 | class Image(Display): 46 | template_name = "widgets/displays/image.html" 47 | 48 | def __init__(self, width: Optional[str] = None, height: Optional[str] = None): 49 | super().__init__(width=width, height=height) 50 | 51 | 52 | class Json(Display): 53 | template_name = "widgets/displays/json.html" 54 | 55 | def __init__(self, dumper: Callable[..., Any] = json.dumps, **context): 56 | super().__init__(**context) 57 | self._dumper = dumper 58 | 59 | async def render(self, request: Request, value: dict): 60 | return await super(Json, self).render(request, self._dumper(value)) 61 | 62 | 63 | class EnumDisplay(Display): 64 | template_name = "" 65 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/components/dropdown.html: -------------------------------------------------------------------------------- 1 | 40 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/inputs/editor.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 6 |
7 |
8 | {% if help_text %} 9 | 10 | {{ help_text }} 11 | 12 | {% endif %} 13 |
14 | 15 | 53 | -------------------------------------------------------------------------------- /fastapi_admin2/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi import HTTPException 4 | from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR 5 | 6 | 7 | class ServerHTTPException(HTTPException): 8 | def __init__(self, error: str = None): 9 | super(ServerHTTPException, self).__init__( 10 | status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail=error 11 | ) 12 | 13 | 14 | class InvalidResource(ServerHTTPException): 15 | """ 16 | raise when has invalid resource 17 | """ 18 | 19 | 20 | class FieldNotFoundError(ServerHTTPException): 21 | """ 22 | raise when no such field for the given 23 | """ 24 | 25 | 26 | class FileMaxSizeLimit(ServerHTTPException): 27 | """ 28 | raise when the upload file exceeds the max size 29 | """ 30 | 31 | 32 | class FileExtNotAllowed(ServerHTTPException): 33 | """ 34 | raise when the upload file ext not allowed 35 | """ 36 | 37 | 38 | class DatabaseError(Exception): 39 | """ 40 | raise when the repository go wrong 41 | """ 42 | 43 | 44 | class RequiredThirdPartyLibNotInstalled(Exception): 45 | def __init__(self, lib_name: str, *, thing_that_cant_work_without_lib: str, 46 | can_be_installed_with_ext: Optional[str] = None): 47 | self.thing_that_cant_work_without_lib = thing_that_cant_work_without_lib 48 | self.can_be_installed_with_ext = can_be_installed_with_ext 49 | self.lib_name = lib_name 50 | 51 | if not self.can_be_installed_with_ext: 52 | self.can_be_installed_with_ext = "" 53 | else: 54 | self.can_be_installed_with_ext = f"[{can_be_installed_with_ext}]" 55 | 56 | super().__init__( 57 | f"{self.thing_that_cant_work_without_lib} can be used only when {self.lib_name} installed\n" 58 | f"Just install {self.lib_name} (`pip install {self.lib_name}`) " 59 | f"or fastapi-admin2 with {self.thing_that_cant_work_without_lib} support" 60 | f" (`pip install fastapi-admin2{self.can_be_installed_with_ext}`)" 61 | ) 62 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/sqla/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Type 4 | 5 | from fastapi import FastAPI 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | from sqlalchemy.orm import sessionmaker 8 | 9 | from fastapi_admin2.backends.sqla.dao.admin_dao import SqlalchemyAdminDao 10 | from fastapi_admin2.backends.sqla.markers import AsyncSessionDependencyMarker, SessionMakerDependencyMarker 11 | from fastapi_admin2.backends.sqla.models import SqlalchemyAdminModel 12 | from fastapi_admin2.backends.sqla.queriers import get_resource_list, delete_resource_by_id, \ 13 | bulk_delete_resources 14 | from fastapi_admin2.providers.security.dependencies import AdminDaoDependencyMarker 15 | from fastapi_admin2.controllers.dependencies import ModelListDependencyMarker, DeleteOneDependencyMarker, \ 16 | DeleteManyDependencyMarker 17 | from . import filters 18 | from .model_resource import Model 19 | 20 | 21 | class SQLAlchemyBackend: 22 | def __init__(self, session_maker: sessionmaker, admin_model_cls: Type[SqlalchemyAdminModel]): 23 | self._session_maker = session_maker 24 | self._admin_model_cls = admin_model_cls 25 | 26 | def configure(self, app: FastAPI) -> None: 27 | # It's not neccessary in all cases, but for some kind of authorization it can be useful 28 | app.dependency_overrides[AdminDaoDependencyMarker] = lambda: SqlalchemyAdminDao( 29 | self._session_maker(), self._admin_model_cls 30 | ) 31 | 32 | async def spin_up_session(): 33 | session: AsyncSession = self._session_maker() 34 | try: 35 | yield session 36 | finally: 37 | await session.close() 38 | 39 | app.dependency_overrides[AsyncSessionDependencyMarker] = spin_up_session 40 | app.dependency_overrides[SessionMakerDependencyMarker] = lambda: self._session_maker 41 | 42 | # route dependencies 43 | app.dependency_overrides[DeleteOneDependencyMarker] = delete_resource_by_id 44 | app.dependency_overrides[DeleteManyDependencyMarker] = bulk_delete_resources 45 | app.dependency_overrides[ModelListDependencyMarker] = get_resource_list 46 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/sqla/dao/admin_dao.py: -------------------------------------------------------------------------------- 1 | from typing import Type, Any, Dict, cast 2 | 3 | from sqlalchemy import select, exists, insert, update 4 | from sqlalchemy.exc import MultipleResultsFound, NoResultFound, SQLAlchemyError 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | 7 | from fastapi_admin2.backends.sqla.models import SqlalchemyAdminModel 8 | from fastapi_admin2.entities import AbstractAdmin 9 | from fastapi_admin2.providers.security.dependencies import EntityNotFound, AdminDaoProto 10 | 11 | 12 | class SqlalchemyAdminDao(AdminDaoProto): 13 | 14 | def __init__(self, session: AsyncSession, admin_model_cls: Type[SqlalchemyAdminModel]): 15 | self._session = session 16 | self._admin_model_cls = admin_model_cls 17 | 18 | async def get_one_admin_by_filters(self, **filters: Any) -> AbstractAdmin: 19 | stmt = select(self._admin_model_cls).filter_by(**filters) 20 | async with self._session.begin(): 21 | try: 22 | return (await self._session.execute(stmt)).scalars().one() 23 | except (MultipleResultsFound, NoResultFound) as ex: 24 | raise AdministratorNotFound(ex) 25 | 26 | async def is_exists_at_least_one_admin(self, **filters: Any) -> bool: 27 | select_statement = select(self._admin_model_cls.id).select_from(self._admin_model_cls).limit(1) 28 | if filters: 29 | select_statement = select_statement.filter_by(**filters) 30 | stmt = exists(select_statement).select() 31 | async with self._session.begin(): 32 | return cast(bool, (await self._session.execute(stmt)).scalar()) 33 | 34 | async def add_admin(self, **values: Any) -> None: 35 | stmt = insert(self._admin_model_cls).values(**values) 36 | async with self._session.begin(): 37 | await self._session.execute(stmt) 38 | 39 | async def update_admin(self, filters: Dict[Any, Any], **values: Any) -> None: 40 | stmt = update(self._admin_model_cls).filter_by(**filters).values(**values) 41 | async with self._session.begin(): 42 | await self._session.execute(stmt) 43 | 44 | 45 | class AdministratorNotFound(EntityNotFound): 46 | def __init__(self, origin_exception: SQLAlchemyError): 47 | self.origin_exception = origin_exception 48 | -------------------------------------------------------------------------------- /fastapi_admin2/utils/files/on_premise.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | from typing import Callable, Optional, Sequence, Union 4 | 5 | import anyio 6 | from starlette.datastructures import UploadFile 7 | 8 | from fastapi_admin2.exceptions import FileExtNotAllowed, FileMaxSizeLimit 9 | from fastapi_admin2.utils.files.base import Link, FileManager 10 | 11 | DEFAULT_MAX_FILE_SIZE = 1024 ** 3 12 | 13 | 14 | class OnPremiseFileManager(FileManager): 15 | def __init__( 16 | self, 17 | uploads_dir: os.PathLike, 18 | allow_extensions: Optional[Sequence[str]] = None, 19 | max_size: int = DEFAULT_MAX_FILE_SIZE, 20 | filename_generator: Optional[Callable[[UploadFile], str]] = None 21 | ): 22 | self._max_size = max_size 23 | self._allow_extensions = allow_extensions 24 | self._uploads_dir = pathlib.Path(uploads_dir) 25 | self._filename_generator = filename_generator 26 | 27 | async def download_file(self, file: UploadFile) -> Union[Link, os.PathLike]: 28 | if self._filename_generator: 29 | filename = self._filename_generator(file) 30 | else: 31 | filename = file.filename 32 | content = await file.read() 33 | file_size = len(content) 34 | if file_size > self._max_size: 35 | raise FileMaxSizeLimit(f"File size {file_size} exceeds max size {self._max_size}") 36 | 37 | if self._file_extension_is_not_allowed(filename): 38 | raise FileExtNotAllowed(f"File ext is not allowed of {self._allow_extensions}") 39 | 40 | return await self._save_file(filename, content) # type: ignore 41 | 42 | async def _save_file(self, filename: str, content: bytes) -> os.PathLike: 43 | """ 44 | Save file to upload directory / filename 45 | 46 | :param filename: 47 | :param content: 48 | :return: relative path to upload directory 49 | """ 50 | path_to_file = self._uploads_dir / filename 51 | async with await anyio.open_file(path_to_file, "wb") as f: 52 | await f.write(content) 53 | return path_to_file 54 | 55 | def _file_extension_is_not_allowed(self, filename: str) -> bool: 56 | if not self._allow_extensions: 57 | return False 58 | return all(not filename.endswith(ext) for ext in self._allow_extensions) 59 | -------------------------------------------------------------------------------- /tests/test_utils/test_file_upload.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | import py.path 4 | import pytest 5 | from fastapi import UploadFile 6 | 7 | from fastapi_admin2.exceptions import FileExtNotAllowed 8 | from fastapi_admin2.utils.files import OnPremiseFileManager, StaticFilesManager 9 | 10 | pytestmark = pytest.mark.asyncio 11 | 12 | 13 | class TestOnPremiseFileUploader: 14 | async def test_upload(self, tmpdir: py.path.local): 15 | uploader = OnPremiseFileManager(uploads_dir=tmpdir) 16 | upload_file = UploadFile(filename="test.txt", file=io.BytesIO(b"test")) 17 | 18 | await uploader.download_file(upload_file) 19 | 20 | assert (tmpdir / "test.txt").isfile() 21 | assert str((tmpdir / "test.txt").read()) == "test" 22 | 23 | async def test_upload_with_allowed_file_extensions(self, tmpdir: py.path.local): 24 | uploader = OnPremiseFileManager(uploads_dir=tmpdir, allow_extensions=["jpeg"]) 25 | upload_file = UploadFile(filename="test.jpeg", file=io.BytesIO(b"test")) 26 | 27 | await uploader.download_file(upload_file) 28 | 29 | assert (tmpdir / "test.jpeg").isfile() 30 | assert str((tmpdir / "test.jpeg").read()) == "test" 31 | 32 | async def test_fail_if_file_extension_not_allowed(self, tmpdir: py.path.local): 33 | uploader = OnPremiseFileManager(uploads_dir=tmpdir, allow_extensions=["jpeg"]) 34 | upload_file = UploadFile(filename="test.txt", file=io.BytesIO(b"test")) 35 | 36 | with pytest.raises(FileExtNotAllowed): 37 | await uploader.download_file(upload_file) 38 | 39 | async def test_save_file(self, tmpdir: py.path.local): 40 | uploader = OnPremiseFileManager(uploads_dir=tmpdir) 41 | 42 | await uploader.save_file("test.txt", b"test") 43 | 44 | assert (tmpdir / "test.txt").isfile() 45 | assert str((tmpdir / "test.txt").read()) == "test" 46 | 47 | 48 | class TestStaticFileUploader: 49 | async def test_upload(self, tmpdir: py.path.local): 50 | uploader = StaticFilesManager(OnPremiseFileManager(uploads_dir=tmpdir, allow_extensions=["jpeg"]), 51 | static_path_prefix="/static/uploads") 52 | upload_file = UploadFile(filename="test.jpeg", file=io.BytesIO(b"test")) 53 | 54 | path_to_file = await uploader.upload(upload_file) 55 | 56 | assert str(path_to_file) == "/static/uploads/test.jpeg" 57 | -------------------------------------------------------------------------------- /fastapi_admin2/depends.py: -------------------------------------------------------------------------------- 1 | from typing import List, Type, Optional, Any, Dict 2 | 3 | from fastapi import Depends, HTTPException 4 | from fastapi.params import Path 5 | from starlette.requests import Request 6 | from starlette.status import HTTP_404_NOT_FOUND 7 | 8 | from fastapi_admin2.exceptions import InvalidResource 9 | from fastapi_admin2.ui.resources import Dropdown, Link, AbstractModelResource, Resource 10 | 11 | 12 | def get_orm_model_by_resource_name( 13 | request: Request, 14 | resource_name: str = Path(..., alias="resource") 15 | ) -> Optional[Type[Any]]: 16 | if not resource_name: 17 | return None 18 | for model_cls in request.app.model_resources.keys(): 19 | if model_cls.__name__.lower() != resource_name.strip().lower(): 20 | continue 21 | return model_cls 22 | raise HTTPException(status_code=HTTP_404_NOT_FOUND) 23 | 24 | 25 | async def get_model_resource( 26 | request: Request, 27 | orm_model: Any = Depends( 28 | get_orm_model_by_resource_name 29 | ) 30 | ) -> AbstractModelResource: 31 | model_resource_type = request.app.get_model_resource_type(orm_model) # type: Optional[Type[AbstractModelResource]] 32 | if not model_resource_type: 33 | raise HTTPException(status_code=HTTP_404_NOT_FOUND) 34 | 35 | return await model_resource_type.from_http_request(request) 36 | 37 | 38 | def get_resources(request: Request) -> List[Dict[str, Any]]: 39 | resources = request.app.resources 40 | r = _get_resources(resources) # TODO replace 41 | return r 42 | 43 | 44 | def _get_resources(resources: List[Type[Resource]]): 45 | ret = [] 46 | for resource in resources: 47 | item = { 48 | "icon": resource.icon, 49 | "label": resource.label, 50 | } 51 | if issubclass(resource, Link): 52 | item["type"] = "link" 53 | item["url"] = resource.url 54 | item["target"] = resource.target 55 | elif issubclass(resource, AbstractModelResource): 56 | item["type"] = "model" 57 | item["model"] = resource.model.__name__.lower() 58 | elif issubclass(resource, Dropdown): 59 | item["type"] = "dropdown" 60 | item["resources"] = _get_resources(resource.resources) 61 | else: 62 | raise InvalidResource("Should be subclass of Resource") 63 | ret.append(item) 64 | return ret 65 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/filters/datetime.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | {{ label }}: 5 |
6 | 10 |
11 |
12 | 54 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/sqla/models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from datetime import datetime 4 | from typing import cast, Any, Dict, Pattern, Final 5 | 6 | from sqlalchemy import Identity, VARCHAR, BIGINT 7 | from sqlalchemy import inspect, Column, TIMESTAMP, func 8 | from sqlalchemy.orm import registry 9 | from sqlalchemy.orm.decl_api import DeclarativeMeta, declarative_mixin 10 | from sqlalchemy.util import ImmutableProperties 11 | 12 | mapper_registry = registry() 13 | 14 | TABLE_NAME_REGEX: Pattern[str] = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z])|(?<=[^A-Z])(?=[A-Z])") 15 | PLURAL: Final[str] = "s" 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class Base(metaclass=DeclarativeMeta): 21 | __abstract__ = True 22 | __mapper_args__ = {"eager_defaults": True} 23 | 24 | # noinspection PyUnusedLocal 25 | def __init__(self, *args: Any, **kwargs: Any) -> None: 26 | for key, value in kwargs.items(): 27 | setattr(self, key, value) 28 | 29 | registry = mapper_registry 30 | metadata = mapper_registry.metadata 31 | 32 | def _get_attributes(self) -> Dict[Any, Any]: 33 | return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} 34 | 35 | def __str__(self) -> str: 36 | attributes = "|".join(str(v) for k, v in self._get_attributes().items()) 37 | return f"{self.__class__.__qualname__} {attributes}" 38 | 39 | def __repr__(self) -> str: 40 | table_attrs = cast(ImmutableProperties, inspect(self).attrs) 41 | primary_keys = " ".join( 42 | f"{key.name}={table_attrs[key.name].value}" 43 | for key in inspect(self.__class__).primary_key 44 | ) 45 | return f"{self.__class__.__qualname__}->{primary_keys}" 46 | 47 | def as_dict(self) -> Dict[Any, Any]: 48 | return self._get_attributes() 49 | 50 | 51 | @declarative_mixin 52 | class TimeStampMixin: 53 | __abstract__ = True 54 | 55 | created_at = Column(TIMESTAMP(timezone=True), server_default=func.now()) 56 | updated_at: datetime = Column( 57 | TIMESTAMP(timezone=True), server_onupdate=func.now(), server_default=func.now() 58 | ) 59 | 60 | 61 | class SqlalchemyAdminModel(Base): 62 | __abstract__ = True 63 | 64 | id = Column(BIGINT(), Identity(always=True, cache=10), primary_key=True) 65 | username = Column(VARCHAR(50), unique=True) 66 | password = Column(VARCHAR(200), nullable=False) 67 | profile_pic = Column(VARCHAR(200), nullable=True) 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | db.sqlite3-journal 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # pipenv 86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 88 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 89 | # install all needed dependencies. 90 | #Pipfile.lock 91 | 92 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 93 | __pypackages__/ 94 | 95 | # Celery stuff 96 | celerybeat-schedule 97 | celerybeat.pid 98 | 99 | # SageMath parsed files 100 | *.sage.py 101 | 102 | # Environments 103 | .env 104 | .venv 105 | env/ 106 | venv/ 107 | ENV/ 108 | env.bak/ 109 | venv.bak/ 110 | 111 | # Spyder project settings 112 | .spyderproject 113 | .spyproject 114 | 115 | # Rope project settings 116 | .ropeproject 117 | 118 | # mkdocs documentation 119 | site/ 120 | 121 | # mypy 122 | fastapi-admin/.mypy_cache/ 123 | .dmypy.json 124 | dmypy.json 125 | 126 | # Pyre type checker 127 | .pyre/ 128 | 129 | .idea 130 | .DS_Store 131 | .vscode 132 | -------------------------------------------------------------------------------- /fastapi_admin2/middlewares/i18n/base.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from abc import ABC, abstractmethod 3 | from typing import Protocol, Optional, Generator 4 | 5 | import pycountry 6 | from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint 7 | from starlette.requests import Request 8 | from starlette.responses import Response 9 | from starlette.types import ASGIApp 10 | 11 | from fastapi_admin2.i18n import Localizer 12 | from fastapi_admin2.i18n.localizer import I18NLocalizer 13 | 14 | 15 | class Language(Protocol): 16 | alpha_3: str 17 | name: str 18 | scope: str 19 | type: str 20 | 21 | 22 | class AbstractI18nMiddleware(BaseHTTPMiddleware, ABC): 23 | 24 | def __init__(self, app: ASGIApp, 25 | translator: Optional[Localizer] = None, ) -> None: 26 | super().__init__(app) 27 | self._translator = translator 28 | if translator is None: 29 | self._translator = I18NLocalizer() 30 | 31 | self._languages = pycountry.languages 32 | iter(self._languages) # avoid pycountry lazy loading 33 | 34 | async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: 35 | current_locale = await self.get_locale(request) 36 | request.state.gettext = functools.partial(self._translator.gettext, locale=current_locale) 37 | request.state.lazy_gettext = functools.partial(self._translator.lazy_gettext, locale=current_locale) 38 | request.state.current_locale = current_locale 39 | 40 | request.app.templates.env.globals['gettext'] = request.state.gettext 41 | request.app.templates.env.globals['current_locale'] = current_locale 42 | request.app.templates.env.globals['available_languages'] = list(self.iter_founded_locales()) 43 | 44 | with self._translator.internationalized(new_locale=current_locale): 45 | response = await call_next(request) 46 | 47 | response.set_cookie(key="language", value=current_locale, path=request.app.admin_path) 48 | return response 49 | 50 | def iter_founded_locales(self) -> Generator[Language, None, None]: 51 | for t in self._translator.available_translations: 52 | try: 53 | lang = self._languages.get(alpha_2=t) 54 | except LookupError: 55 | continue 56 | 57 | if lang is None: 58 | continue 59 | 60 | yield lang 61 | 62 | @abstractmethod 63 | async def get_locale(self, request: Request) -> str: 64 | """ 65 | Detect current user locale based on request. 66 | **This method must be defined in child classes** 67 | 68 | :param request: 69 | :return: 70 | """ 71 | -------------------------------------------------------------------------------- /fastapi_admin2/utils/templating.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from datetime import date 3 | from pathlib import Path 4 | from typing import Any, Dict, Optional 5 | 6 | from jinja2 import pass_context, Environment, FileSystemLoader, select_autoescape, FileSystemBytecodeCache 7 | from starlette.background import BackgroundTask 8 | from starlette.requests import Request 9 | from starlette.responses import HTMLResponse 10 | 11 | from fastapi_admin2.default_settings import BASE_DIR 12 | 13 | 14 | class JinjaTemplates: 15 | 16 | def __init__(self, directory: Optional[Path] = None): 17 | self._directory = directory 18 | if self._directory is None: 19 | self._directory = BASE_DIR / "templates" 20 | self.env = self._create_env() 21 | 22 | async def create_html_response( 23 | self, 24 | template_name: str, 25 | context: Optional[Dict[str, Any]] = None, 26 | status_code: int = 200, 27 | headers: Optional[Dict[str, Any]] = None, 28 | media_type: Optional[str] = None, 29 | background: Optional[BackgroundTask] = None 30 | ) -> HTMLResponse: 31 | if headers is None: 32 | headers = {} 33 | 34 | content = await self._render_content(supplement_template_name(template_name), context) 35 | return HTMLResponse( 36 | content=content, 37 | status_code=status_code, 38 | headers={ 39 | "Cache-Control": "no-cache", 40 | "Pragma": "no-cache", 41 | **headers 42 | }, 43 | media_type=media_type, 44 | background=background, 45 | ) 46 | 47 | async def _render_content(self, template_name: str, context: Optional[Dict[str, Any]] = None) -> str: 48 | if context is None: 49 | context = {} 50 | template = self.env.get_template(template_name) 51 | return await template.render_async(context) 52 | 53 | def _create_env(self) -> Environment: 54 | env = Environment( 55 | loader=FileSystemLoader(self._directory), 56 | autoescape=select_autoescape(["html", "xml"]), 57 | bytecode_cache=FileSystemBytecodeCache(), 58 | enable_async=True 59 | ) 60 | 61 | env.globals["url_for"] = url_for 62 | env.globals["NOW_YEAR"] = date.today().year 63 | 64 | return env 65 | 66 | 67 | @pass_context 68 | def url_for(context: Dict[str, Any], name: str, **path_params: Any) -> str: 69 | request: Request = context["request"] 70 | return request.url_for(name, **path_params) 71 | 72 | 73 | @functools.lru_cache(1200) 74 | def supplement_template_name(name: str) -> str: 75 | if not name.endswith(".html"): 76 | return name + ".html" 77 | return name 78 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/sqla/queriers.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import Depends 4 | from sqlalchemy import select, func, delete 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | from starlette.requests import Request 7 | 8 | from fastapi_admin2.entities import ResourceList 9 | from fastapi_admin2.depends import get_model_resource, get_orm_model_by_resource_name 10 | from fastapi_admin2.backends.sqla.markers import AsyncSessionDependencyMarker 11 | from fastapi_admin2.backends.sqla.toolings import include_where_condition_by_pk 12 | from fastapi_admin2.ui.resources.model import AbstractModelResource 13 | 14 | 15 | async def get_resource_list(request: Request, 16 | model_resource: AbstractModelResource = Depends(get_model_resource), 17 | page_size: int = 10, 18 | model=Depends(get_orm_model_by_resource_name), page_num: int = 1, 19 | session: AsyncSession = Depends(AsyncSessionDependencyMarker)) -> ResourceList: 20 | select_stmt = select( 21 | model, func.count("*").over().label("entry_count") 22 | ).select_from(model) 23 | select_stmt = await model_resource.enrich_select_with_filters( 24 | request=request, 25 | model=model, 26 | query=select_stmt 27 | ) 28 | 29 | page_size = page_size 30 | if page_size: 31 | select_stmt = select_stmt.limit(page_size) 32 | else: 33 | page_size = model_resource.page_size 34 | 35 | select_stmt = select_stmt.offset((page_num - 1) * page_size) 36 | 37 | async with session.begin(): 38 | rows = (await session.execute(select_stmt)).all() 39 | 40 | try: 41 | total_entries_count = rows[0][1] 42 | orm_models = [row[0] for row in rows] 43 | return ResourceList(models=orm_models, total_entries_count=total_entries_count) 44 | except IndexError: 45 | return ResourceList() 46 | 47 | 48 | async def delete_resource_by_id(id_: str, session: AsyncSession = Depends(AsyncSessionDependencyMarker), 49 | model: Any = Depends(get_orm_model_by_resource_name)) -> None: 50 | stmt = include_where_condition_by_pk(delete(model), model, id_, 51 | dialect_name=session.bind.dialect.name) 52 | async with session.begin(): 53 | await session.execute(stmt) 54 | 55 | 56 | async def bulk_delete_resources(ids: str, model: Any = Depends(get_orm_model_by_resource_name), 57 | session: AsyncSession = Depends(AsyncSessionDependencyMarker)) -> None: 58 | stmt = include_where_condition_by_pk(delete(model), model, ids.split(","), 59 | dialect_name=session.bind.dialect.name) 60 | async with session.begin(): 61 | await session.execute(stmt) 62 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/sqla/field_converters.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column 2 | 3 | from fastapi_admin2.ui.resources.model import ColumnToFieldConverter, FieldSpec 4 | from fastapi_admin2.ui.widgets import displays, inputs 5 | 6 | 7 | class BooleanColumnToFieldConverter(ColumnToFieldConverter): 8 | def _convert_column_to_field_spec(self, column: Column) -> FieldSpec: 9 | return FieldSpec( 10 | display=displays.Boolean(), 11 | input_=inputs.Switch(null=column.nullable, default=column.default), 12 | ) 13 | 14 | 15 | class DatetimeColumnToFieldConverter(ColumnToFieldConverter): 16 | def _convert_column_to_field_spec(self, column: Column) -> FieldSpec: 17 | if column.default or column.server_default: 18 | input_ = inputs.DisplayOnly() 19 | else: 20 | input_ = inputs.DateTime(null=column.nullable, default=column.server_default) 21 | 22 | return FieldSpec( 23 | display=displays.DatetimeDisplay(), 24 | input_=input_ 25 | ) 26 | 27 | 28 | class DateColumnToFieldConverter(ColumnToFieldConverter): 29 | def _convert_column_to_field_spec(self, column: Column) -> FieldSpec: 30 | return FieldSpec( 31 | display=displays.DateDisplay(), 32 | input_=inputs.Date(null=column.nullable, default=column.default), 33 | ) 34 | 35 | 36 | class EnumColumnToFieldConverter(ColumnToFieldConverter): 37 | def _convert_column_to_field_spec(self, column: Column) -> FieldSpec: 38 | return FieldSpec( 39 | display=displays.Display(), 40 | input_=inputs.Enum( 41 | column.type.__class__, 42 | null=column.nullable, 43 | default=column.default 44 | ) 45 | ) 46 | 47 | 48 | class JSONColumnToFieldConverter(ColumnToFieldConverter): 49 | def _convert_column_to_field_spec(self, column: Column) -> FieldSpec: 50 | return FieldSpec( 51 | display=displays.Json(), 52 | input_=inputs.Json(null=column.nullable) 53 | ) 54 | 55 | 56 | class StringColumnToFieldConverter(ColumnToFieldConverter): 57 | def _convert_column_to_field_spec(self, column: Column) -> FieldSpec: 58 | placeholder = column.description or "" 59 | return FieldSpec( 60 | display=displays.Display(), 61 | input_=inputs.TextArea( 62 | placeholder=placeholder, null=column.nullable, default=column.default 63 | ) 64 | ) 65 | 66 | 67 | class IntegerColumnToFieldConverter(ColumnToFieldConverter): 68 | def _convert_column_to_field_spec(self, column: Column) -> FieldSpec: 69 | placeholder = column.description or "" 70 | return FieldSpec( 71 | display=displays.Display(), 72 | input_=inputs.Number( 73 | placeholder=placeholder, null=column.nullable, default=column.default 74 | ) 75 | ) 76 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/init.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block outer_body %} 3 | 4 |
5 |
6 | {% include "components/alert_error.html" %} 7 |
9 |
10 |

{{ _('Create first admin') }}

11 |
12 | 13 | 15 |
16 |
17 | 22 |
23 | 25 | 26 | 27 |
28 |
29 |
30 | 35 |
36 | 39 | 40 | 41 |
42 |
43 |
44 |
{{ _('profile_pic') }}
45 | 46 |
47 | 50 |
51 |
52 |
53 |
54 | 55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/widgets/inputs/datetime.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |
6 | 7 | 21 | 22 | 29 |
30 |
31 | -------------------------------------------------------------------------------- /fastapi_admin2/templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} {% block body %} 2 |
3 | 27 |
28 | 53 |
54 | 64 |
65 |
66 |
67 |
68 | {% block page_body %} {% endblock %} 69 |
70 |
71 |
72 |
73 |
74 | {% endblock %} -------------------------------------------------------------------------------- /fastapi_admin2/templates/providers/login/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} {% block outer_body %} 2 | 3 |
4 |
5 |
6 | {% if login_logo_url %} 7 | 13 | {% endif %} 14 |
15 | {% if error %} 16 | 32 | {% endif %} 33 |
39 |
40 |

41 | {{ _('login_title') }} 42 |

43 |
44 | 45 | 51 |
52 |
53 | 57 |
58 | 65 | 66 |
67 |
68 |
69 | 77 |
78 | 83 |
84 |
85 |
86 |
87 | 88 | 94 | 95 | {% endblock %} -------------------------------------------------------------------------------- /fastapi_admin2/backends/tortoise/field_converters.py: -------------------------------------------------------------------------------- 1 | from tortoise import ForeignKeyFieldInstance, ManyToManyFieldInstance 2 | from tortoise.fields import DatetimeField, BooleanField, IntField, DateField, JSONField 3 | from tortoise.fields.base import Field as TortoiseField 4 | from tortoise.fields.data import IntEnumFieldInstance, CharEnumFieldInstance 5 | 6 | from fastapi_admin2.backends.tortoise.widgets.inputs import ForeignKey, ManyToMany 7 | from fastapi_admin2.resources.model import ColumnToFieldConverter, FieldSpec 8 | from fastapi_admin2.widgets import displays, inputs 9 | 10 | 11 | class BooleanColumnToFieldConverter(ColumnToFieldConverter): 12 | def _convert_column_to_field_spec(self, column: BooleanField) -> FieldSpec: 13 | return FieldSpec( 14 | display=displays.Boolean(), 15 | input_=inputs.Switch(null=column.null, default=column.default), 16 | ) 17 | 18 | 19 | class DatetimeColumnToFieldConverter(ColumnToFieldConverter): 20 | def _convert_column_to_field_spec(self, column: DatetimeField) -> FieldSpec: 21 | if column.auto_now or column.auto_now_add: 22 | input_ = inputs.DisplayOnly() 23 | else: 24 | input_ = inputs.DateTime(null=column.null, default=column.default) 25 | 26 | return FieldSpec( 27 | display=displays.DatetimeDisplay(), 28 | input_=input_ 29 | ) 30 | 31 | 32 | class DateColumnToFieldConverter(ColumnToFieldConverter): 33 | def _convert_column_to_field_spec(self, column: TortoiseField) -> FieldSpec: 34 | return FieldSpec( 35 | display=displays.DateDisplay(), 36 | input_=inputs.Date(null=column.null, default=column.default), 37 | ) 38 | 39 | 40 | class IntEnumColumnToFieldConverter(ColumnToFieldConverter): 41 | def _convert_column_to_field_spec(self, column: IntEnumFieldInstance) -> FieldSpec: 42 | return FieldSpec( 43 | display=displays.Display(), 44 | input_=inputs.Enum( 45 | column.enum_type, 46 | null=column.null, 47 | default=column.default 48 | ) 49 | ) 50 | 51 | 52 | class CharEnumColumnToFieldConverter(ColumnToFieldConverter): 53 | def _convert_column_to_field_spec(self, column: CharEnumFieldInstance) -> FieldSpec: 54 | return FieldSpec( 55 | display=displays.Display(), 56 | input_=inputs.Enum( 57 | column.enum_type, 58 | null=column.null, 59 | default=column.default, 60 | enum_type=str 61 | ) 62 | ) 63 | 64 | 65 | class JSONColumnToFieldConverter(ColumnToFieldConverter): 66 | def _convert_column_to_field_spec(self, column: TortoiseField) -> FieldSpec: 67 | return FieldSpec( 68 | display=displays.Json(), 69 | input_=inputs.Json(null=column.null) 70 | ) 71 | 72 | 73 | class TextColumnToFieldConverter(ColumnToFieldConverter): 74 | def _convert_column_to_field_spec(self, column: TortoiseField) -> FieldSpec: 75 | placeholder = column.description or "" 76 | return FieldSpec( 77 | display=displays.Display(), 78 | input_=inputs.TextArea( 79 | placeholder=placeholder, null=column.null, default=column.default 80 | ) 81 | ) 82 | 83 | 84 | class IntegerColumnToFieldConverter(ColumnToFieldConverter): 85 | def _convert_column_to_field_spec(self, column: TortoiseField) -> FieldSpec: 86 | placeholder = column.description or "" 87 | return FieldSpec( 88 | display=displays.Display(), 89 | input_=inputs.Number( 90 | placeholder=placeholder, null=column.null, default=column.default 91 | ) 92 | ) 93 | 94 | 95 | class ForeignKeyToFieldConverter(ColumnToFieldConverter): 96 | def _convert_column_to_field_spec(self, column: ForeignKeyFieldInstance) -> FieldSpec: 97 | return FieldSpec( 98 | display=displays.Display(), 99 | input_=ForeignKey( 100 | column.related_model, null=column.null, default=column.default 101 | ), 102 | field_name=column.source_field 103 | ) 104 | 105 | 106 | class ManyToManyFieldConverter(ColumnToFieldConverter): 107 | def _convert_column_to_field_spec(self, column: ManyToManyFieldInstance) -> FieldSpec: 108 | return FieldSpec( 109 | display=displays.InputOnly(), 110 | input_=ManyToMany(column.related_model) 111 | ) 112 | -------------------------------------------------------------------------------- /tests/test_i18n.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | import pytest 4 | from fastapi import FastAPI 5 | from starlette.requests import Request 6 | 7 | from fastapi_admin2.i18n import Localizer, I18nMiddleware 8 | from fastapi_admin2.i18n import get_i18n, lazy_gettext, gettext 9 | from tests.conftest import DATA_DIR 10 | 11 | 12 | @pytest.fixture(name="i18n") 13 | def i18n_fixture() -> Localizer: 14 | return Localizer(path_to_default_translations=DATA_DIR / "locales") 15 | 16 | 17 | @pytest.fixture(name="i18n_extra") 18 | def i18n_fixture_with_extra_translations(): 19 | return Localizer(path_to_default_translations=DATA_DIR / "locales", 20 | path_to_extra_translations=DATA_DIR / "extra_locales") 21 | 22 | 23 | class TestI18nCore: 24 | def test_init(self, i18n: Localizer): 25 | assert set(i18n.available_locales) == {"en", "uk"} 26 | 27 | def test_reload(self, i18n: Localizer): 28 | i18n.reload_locales() 29 | assert set(i18n.available_locales) == {"en", "uk"} 30 | 31 | def test_current_locale(self, i18n: Localizer): 32 | assert i18n.current_locale == "en" 33 | i18n.current_locale = "uk" 34 | assert i18n.current_locale == "uk" 35 | assert i18n.ctx_locale.get() == "uk" 36 | 37 | def test_use_locale(self, i18n: Localizer): 38 | assert i18n.current_locale == "en" 39 | with i18n.use_locale("uk"): 40 | assert i18n.current_locale == "uk" 41 | with i18n.use_locale("it"): 42 | assert i18n.current_locale == "it" 43 | assert i18n.current_locale == "uk" 44 | assert i18n.current_locale == "en" 45 | 46 | def test_get_i18n(self, i18n: Localizer): 47 | with pytest.raises(LookupError): 48 | get_i18n() 49 | 50 | with i18n.internationalized(): 51 | assert get_i18n() == i18n 52 | 53 | @pytest.mark.parametrize( 54 | "locale,case,result", 55 | [ 56 | [None, dict(singular="test"), "test"], 57 | [None, dict(singular="test", locale="uk"), "тест"], 58 | ["en", dict(singular="test", locale="uk"), "тест"], 59 | ["uk", dict(singular="test", locale="uk"), "тест"], 60 | ["uk", dict(singular="test"), "тест"], 61 | ["it", dict(singular="test"), "test"], 62 | [None, dict(singular="test", n=2), "test"], 63 | [None, dict(singular="test", n=2, locale="uk"), "тест"], 64 | ["en", dict(singular="test", n=2, locale="uk"), "тест"], 65 | ["uk", dict(singular="test", n=2, locale="uk"), "тест"], 66 | ["uk", dict(singular="test", n=2), "тест"], 67 | ["it", dict(singular="test", n=2), "test"], 68 | [None, dict(singular="test", plural="test2", n=2), "test2"], 69 | [None, dict(singular="test", plural="test2", n=2, locale="uk"), "test2"], 70 | ["en", dict(singular="test", plural="test2", n=2, locale="uk"), "test2"], 71 | ["uk", dict(singular="test", plural="test2", n=2, locale="uk"), "test2"], 72 | ["uk", dict(singular="test", plural="test2", n=2), "test2"], 73 | ["it", dict(singular="test", plural="test2", n=2), "test2"], 74 | ], 75 | ) 76 | def test_gettext(self, i18n: Localizer, locale: str, case: Dict[str, Any], result: str): 77 | if locale is not None: 78 | i18n.current_locale = locale 79 | with i18n.internationalized(): 80 | assert i18n.gettext(**case) == result 81 | assert str(i18n.lazy_gettext(**case)) == result 82 | assert gettext(**case) == result 83 | assert str(lazy_gettext(**case)) == result 84 | 85 | 86 | async def next_call(r: Request): 87 | return gettext("test") 88 | 89 | 90 | @pytest.mark.asyncio 91 | class TestSimpleI18nMiddleware: 92 | @pytest.mark.parametrize( 93 | "req,expected_result", 94 | [ 95 | [Request(scope={ 96 | "type": "http", 97 | "query_string": b"language=uk" 98 | }), "тест"], 99 | [Request(scope={ 100 | "type": "http", 101 | "query_string": b'language=en' 102 | }), "test"], 103 | ], 104 | ) 105 | async def test_middleware(self, i18n: Localizer, req: Request, expected_result: str): 106 | middleware = I18nMiddleware(app=FastAPI(), translator=i18n) 107 | result = await middleware.dispatch( 108 | req, 109 | next_call 110 | ) 111 | assert result == expected_result 112 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/sqla/filters.py: -------------------------------------------------------------------------------- 1 | from enum import Enum as EnumCLS 2 | from typing import Any, Callable, Dict, Optional, Type 3 | 4 | from sqlalchemy import between, false, true, Column, func 5 | from sqlalchemy.sql import Select 6 | from sqlalchemy.sql.operators import ilike_op, like_op, match_op, is_ 7 | 8 | from fastapi_admin2.backends.sqla.toolings import parse_like_term 9 | from fastapi_admin2.default_settings import DATE_FORMAT_MOMENT 10 | from fastapi_admin2.ui.widgets.filters import BaseSearchFilter, BaseDateRangeFilter, BaseDateTimeRangeFilter, \ 11 | BaseEnumFilter, BaseBooleanFilter, DateRangeDTO 12 | 13 | full_text_search_op = match_op 14 | 15 | 16 | class Search(BaseSearchFilter): 17 | 18 | def __init__( 19 | self, 20 | column: Column, 21 | name: str, 22 | sqlalchemy_operator: Callable[[Any, Any], Any] = ilike_op, 23 | full_text_search_config: Optional[Dict[str, Any]] = None, 24 | placeholder: str = "", 25 | null: bool = True, 26 | **additional_context: Any 27 | ) -> None: 28 | super().__init__(name=name, placeholder=placeholder, null=null, **additional_context) 29 | self._column = column 30 | 31 | if full_text_search_config is not None and sqlalchemy_operator != full_text_search_op: 32 | raise Exception( 33 | "If you wanna to use full-text search, transmit match_op as `sqlalchemy_operator`" 34 | ) 35 | 36 | self._full_text_search_config = full_text_search_config 37 | self._sqlalchemy_operator = sqlalchemy_operator 38 | 39 | def _apply_to_sql_query(self, query: Select, value: str) -> Select: 40 | if self._sqlalchemy_operator in {ilike_op, like_op}: 41 | return query.where(self._sqlalchemy_operator(self._column, value)) 42 | 43 | return query.where(self._sqlalchemy_operator(self._column, value)) 44 | 45 | def clean(self, value: Any) -> Any: 46 | if self._sqlalchemy_operator in {ilike_op, like_op}: 47 | return parse_like_term(value) 48 | 49 | return func.plainto_tsquery(value) 50 | 51 | 52 | class DateRange(BaseDateRangeFilter): 53 | 54 | def __init__(self, column: Column, name: str, 55 | date_format: str = DATE_FORMAT_MOMENT, 56 | placeholder: str = "", 57 | null: bool = True, 58 | **additional_context: Any): 59 | super().__init__(name, date_format, placeholder, null, **additional_context) 60 | self._column = column 61 | 62 | def _apply_to_sql_query(self, query: Select, value: DateRangeDTO) -> Select: 63 | return query.where(between(self._column, value.start, value.end)) 64 | 65 | 66 | class DateTimeRange(BaseDateTimeRangeFilter): 67 | def __init__(self, column: Column, name: str, date_format: str = DATE_FORMAT_MOMENT, placeholder: str = "", 68 | null: bool = True, **additional_context: Any): 69 | super().__init__(name, date_format, placeholder, null, **additional_context) 70 | self._column = column 71 | 72 | def _apply_to_sql_query(self, query: Select, value: DateRangeDTO) -> Select: 73 | return query.where(between(self._column, value.start, value.end)) 74 | 75 | 76 | class Enum(BaseEnumFilter): 77 | def __init__( 78 | self, 79 | column: Column, 80 | enum: Type[EnumCLS], 81 | name: str, 82 | enum_type: Type[Any] = int, 83 | placeholder: str = "", 84 | null: bool = True, 85 | **additional_context: Any 86 | ) -> None: 87 | super().__init__(enum, name, enum_type, placeholder, null, **additional_context) 88 | self._column = column 89 | 90 | def _apply_to_sql_query(self, query: Select, value: Any) -> Select: 91 | return query.where(self._column == value) 92 | 93 | 94 | class Boolean(BaseBooleanFilter): 95 | def __init__( 96 | self, 97 | column: Column, 98 | name: str, 99 | placeholder: str = "", 100 | null: bool = True, 101 | **additional_context: Any 102 | ) -> None: 103 | super().__init__(name, placeholder, null, **additional_context) 104 | self._column = column 105 | 106 | def clean(self, value: Any) -> Any: 107 | if not value: 108 | return false() 109 | 110 | if value == "true": 111 | return true() 112 | 113 | return false() 114 | 115 | def _apply_to_sql_query(self, query: Select, value: Any) -> Select: 116 | value = self.clean(value) 117 | return query.where(is_(self._column, value)) 118 | -------------------------------------------------------------------------------- /fastapi_admin2/i18n/localizer.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import contextlib 3 | from gettext import GNUTranslations 4 | from pathlib import Path 5 | from typing import Dict, Optional, ContextManager, Set 6 | 7 | from fastapi_admin2.default_settings import PATH_TO_LOCALES 8 | from fastapi_admin2.i18n.lazy_proxy import LazyProxy 9 | 10 | 11 | class Localizer(abc.ABC): 12 | @abc.abstractmethod 13 | def gettext( 14 | self, singular: str, plural: Optional[str] = None, n: int = 1, locale: Optional[str] = None 15 | ) -> str: 16 | pass 17 | 18 | @abc.abstractmethod 19 | def lazy_gettext( 20 | self, singular: str, plural: Optional[str] = None, n: int = 1, locale: Optional[str] = None 21 | ) -> LazyProxy: 22 | pass 23 | 24 | @property 25 | @abc.abstractmethod 26 | def available_translations(self) -> Set[str]: 27 | pass 28 | 29 | @abc.abstractmethod 30 | def reload_locales(self) -> None: 31 | pass 32 | 33 | @property 34 | @abc.abstractmethod 35 | def default_locale(self) -> str: 36 | pass 37 | 38 | @abc.abstractmethod 39 | def internationalized(self, new_locale: str) -> ContextManager[None]: 40 | pass 41 | 42 | 43 | class I18NLocalizer(Localizer): 44 | def __init__( 45 | self, 46 | *, 47 | path_to_default_translations: Path = PATH_TO_LOCALES, 48 | path_to_extra_translations: Optional[Path] = None, 49 | default_locale: str = "en", 50 | domain: str = "messages", 51 | ) -> None: 52 | self._path_to_extra_translations = path_to_extra_translations 53 | self._default_locale = default_locale 54 | self._domain = domain 55 | self._path_to_default_translations = path_to_default_translations 56 | self._locales = self._find_locales() 57 | self._current_locale = self._default_locale 58 | 59 | def reload_locales(self) -> None: 60 | self._locales = self._find_locales() 61 | 62 | @property 63 | def default_locale(self) -> str: 64 | return self._default_locale 65 | 66 | @property 67 | def available_translations(self) -> Set[str]: 68 | return set(self._locales.keys()) 69 | 70 | @contextlib.contextmanager 71 | def internationalized(self, new_locale: str) -> ContextManager[None]: 72 | previous_locale = self._current_locale 73 | try: 74 | self._current_locale = new_locale 75 | yield 76 | finally: 77 | self._current_locale = previous_locale 78 | 79 | def gettext( 80 | self, singular: str, plural: Optional[str] = None, n: int = 1, locale: Optional[str] = None 81 | ) -> str: 82 | locale = self._current_locale 83 | 84 | if locale not in self._locales: 85 | if n == 1: 86 | return singular 87 | return plural if plural else singular 88 | 89 | translator = self._locales[locale] 90 | 91 | if plural is None: 92 | return translator.gettext(singular) 93 | return translator.ngettext(singular, plural, n) 94 | 95 | def lazy_gettext( 96 | self, singular: str, plural: Optional[str] = None, n: int = 1, locale: Optional[str] = None 97 | ) -> LazyProxy: 98 | return LazyProxy(self.gettext, singular=singular, plural=plural, n=n, locale=locale) 99 | 100 | def _find_locales(self) -> Dict[str, GNUTranslations]: 101 | """ 102 | Load all compiled locales from path 103 | 104 | :return: dict with locales 105 | """ 106 | translations = self._get_default_translations() 107 | 108 | if self._path_to_extra_translations: 109 | translations.update(**self._parse_translations(self._path_to_extra_translations)) 110 | return translations 111 | 112 | def _get_default_translations(self) -> Dict[str, GNUTranslations]: 113 | return self._parse_translations(self._path_to_default_translations) 114 | 115 | def _parse_translations(self, path: Path) -> Dict[str, GNUTranslations]: 116 | translations: Dict[str, GNUTranslations] = {} 117 | 118 | for file in path.iterdir(): # in linux directory is a file 119 | if not file.is_dir(): 120 | continue 121 | compiled_translation = file / "LC_MESSAGES" / (self._domain + ".mo") 122 | 123 | if compiled_translation.exists(): 124 | with open(compiled_translation, "rb") as fp: 125 | translations[file.stem] = GNUTranslations(fp) 126 | elif compiled_translation.with_suffix(".po"): # pragma: no cover 127 | raise RuntimeError(f"Found locale '{file}' but this language is not compiled!") 128 | 129 | return translations 130 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/sqla/model_resource.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union, Optional, Sequence, Any, Type 2 | 3 | from sqlalchemy import Column, inspect, Boolean, DateTime, Date, String, Integer, Enum, JSON 4 | from sqlalchemy.orm import DeclarativeMeta 5 | from starlette.datastructures import FormData 6 | from starlette.requests import Request 7 | 8 | from fastapi_admin2.backends.sqla.field_converters import ( 9 | BooleanColumnToFieldConverter, 10 | DatetimeColumnToFieldConverter, 11 | DateColumnToFieldConverter, 12 | EnumColumnToFieldConverter, 13 | JSONColumnToFieldConverter, 14 | StringColumnToFieldConverter, 15 | IntegerColumnToFieldConverter 16 | ) 17 | from fastapi_admin2.backends.sqla.filters import Search 18 | from fastapi_admin2.ui.resources import AbstractModelResource 19 | from fastapi_admin2.ui.resources.column import Field, ComputedField 20 | from fastapi_admin2.ui.resources.model import Q 21 | from fastapi_admin2.ui.widgets import inputs, displays 22 | 23 | 24 | class Model(AbstractModelResource): 25 | _default_filter = Search 26 | 27 | def __init__(self): 28 | super().__init__() 29 | self._fields = self._scaffold_fields(inspect(self.model).columns.items()) 30 | self._converters = { 31 | Boolean: BooleanColumnToFieldConverter(), 32 | DateTime: DatetimeColumnToFieldConverter(), 33 | Date: DateColumnToFieldConverter(), 34 | String: StringColumnToFieldConverter(), 35 | Integer: IntegerColumnToFieldConverter(), 36 | Enum: EnumColumnToFieldConverter(), 37 | JSON: JSONColumnToFieldConverter() 38 | } 39 | self._converters.update(self.converters) 40 | 41 | async def enrich_select_with_filters(self, request: Request, model: Any, query: Q) -> Q: 42 | query_params = {k: v for k, v in request.query_params.items() if v} 43 | for filter_ in self._normalized_filters: 44 | query = filter_.apply(query, query_params.get(filter_.name)) 45 | return query 46 | 47 | async def resolve_form_data(self, data: FormData): 48 | for field in self.input_fields: 49 | field_input = field.input 50 | if field_input.internationalized.get("disabled") or isinstance(field_input, inputs.DisplayOnly): 51 | continue 52 | 53 | input_name: Optional[str] = field_input.internationalized.get("name") 54 | if not isinstance(field_input, inputs.BaseManyToManyInput): 55 | continue 56 | 57 | v = await field_input.parse(data.getlist(input_name)) 58 | 59 | def _scaffold_model_fields_for_display(self) -> List[Field]: 60 | sqlalchemy_model_columns: Sequence[Column] = inspect(self.model).columns.items() 61 | fields: List[Field] = [] 62 | 63 | for field in self._scaffold_fields(sqlalchemy_model_columns): 64 | if isinstance(field, str): 65 | field = self._create_field_by_field_name(field) 66 | if isinstance(field.display, displays.InputOnly): 67 | continue 68 | fields.append(field) 69 | 70 | return self._shift_primary_keys_to_beginning(fields) 71 | 72 | def _shift_primary_keys_to_beginning(self, fields: List[Field]) -> List[Field]: 73 | pk_columns: Sequence[Column] = inspect(self.model).primary_key 74 | pk_columns_names = [c.name for c in pk_columns] 75 | for index, field in enumerate(fields): 76 | if field.name not in pk_columns_names: 77 | continue 78 | primary_key_not_at_the_beginning = index != 0 79 | if primary_key_not_at_the_beginning: 80 | fields.remove(field) 81 | fields.insert(0, field) 82 | return fields 83 | 84 | def _scaffold_fields( 85 | self, 86 | sqlalchemy_model_columns: Sequence[Column] = () 87 | ) -> Sequence[Union[str, Field, ComputedField]]: 88 | field_iterator = self.fields 89 | if not field_iterator: 90 | field_iterator = [ 91 | self._create_field_by_field_name(column.name) 92 | for column in sqlalchemy_model_columns 93 | ] 94 | return field_iterator 95 | 96 | def _get_column_by_name(self, name: str) -> Any: 97 | return self.model.__dict__.get(name) 98 | 99 | def _convert_column_for_which_no_converter_found(self, column: Column, field_name: str) -> Field: 100 | placeholder = column.description or "" 101 | return Field( 102 | display=displays.Display(), 103 | input_=inputs.Input( 104 | placeholder=placeholder, null=column.nullable, default=column.default 105 | ), 106 | name=field_name, 107 | label=field_name.title() 108 | ) 109 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/tortoise/model_resource.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Type 2 | 3 | from starlette.datastructures import FormData 4 | from starlette.requests import Request 5 | from tortoise import Model as TortoiseModel, ForeignKeyFieldInstance, ManyToManyFieldInstance 6 | from tortoise.fields import BooleanField, DatetimeField, DateField, JSONField, TextField, IntField 7 | from tortoise.fields.base import Field as TortoiseField 8 | from tortoise.fields.data import CharEnumFieldInstance, IntEnumFieldInstance 9 | 10 | from fastapi_admin2.backends.tortoise.field_converters import BooleanColumnToFieldConverter, \ 11 | DatetimeColumnToFieldConverter, DateColumnToFieldConverter, TextColumnToFieldConverter, \ 12 | IntegerColumnToFieldConverter, JSONColumnToFieldConverter, CharEnumColumnToFieldConverter, \ 13 | IntEnumColumnToFieldConverter, ForeignKeyToFieldConverter, ManyToManyFieldConverter 14 | from fastapi_admin2.backends.tortoise.filters import Search 15 | from fastapi_admin2.backends.tortoise.widgets.inputs import ManyToMany 16 | from fastapi_admin2.resources import Field 17 | from fastapi_admin2.resources.model import AbstractModelResource, Q 18 | from fastapi_admin2.widgets import displays, inputs 19 | from fastapi_admin2.widgets.inputs import DisplayOnly 20 | 21 | 22 | class Model(AbstractModelResource): 23 | model: Type[TortoiseModel] 24 | _default_filter = Search 25 | 26 | def __init__(self): 27 | super().__init__() 28 | self._pk_column_name = self.model._meta.db_pk_column 29 | self._converters = { 30 | CharEnumFieldInstance: CharEnumColumnToFieldConverter(), 31 | IntEnumFieldInstance: IntEnumColumnToFieldConverter(), 32 | ForeignKeyFieldInstance: ForeignKeyToFieldConverter(), 33 | ManyToManyFieldInstance: ManyToManyFieldConverter(), 34 | DatetimeField: DatetimeColumnToFieldConverter(), 35 | BooleanField: BooleanColumnToFieldConverter(), 36 | IntField: IntegerColumnToFieldConverter(), 37 | DateField: DateColumnToFieldConverter(), 38 | TextField: TextColumnToFieldConverter(), 39 | JSONField: JSONColumnToFieldConverter(), 40 | } 41 | self._converters.update(self.converters) 42 | 43 | async def enrich_select_with_filters(self, request: Request, model: Any, query: Q) -> Q: 44 | query_params = {k: v for k, v in request.query_params.items() if v} 45 | for filter_ in self._normalized_filters: 46 | query = filter_.apply(query, query_params.get(filter_.name)) 47 | 48 | return query 49 | 50 | async def resolve_form_data(self, data: FormData): 51 | ret = {} 52 | m2m_ret = {} 53 | for field in self.input_fields: 54 | input_ = field.input 55 | if input_.internationalized.get("disabled") or isinstance(input_, DisplayOnly): 56 | continue 57 | name = input_.internationalized.get("name") 58 | if isinstance(input_, ManyToMany): 59 | v = data.getlist(name) 60 | value = await input_.parse(v) 61 | m2m_ret[name] = await input_.model.filter(pk__in=value) 62 | else: 63 | v = data.get(name) 64 | value = await input_.parse(v) 65 | if value is None: 66 | continue 67 | ret[name] = value 68 | return ret, m2m_ret 69 | 70 | def _scaffold_model_fields_for_display(self) -> List[Field]: 71 | fields: List[Field] = [] 72 | for field in self.fields or self.model._meta.fields: 73 | if isinstance(field, str): 74 | if field == self._pk_column_name: 75 | continue 76 | field = self._create_field_by_field_name(field) 77 | if isinstance(field, str): 78 | field = self._create_field_by_field_name(field) 79 | if isinstance(field.display, displays.InputOnly): 80 | continue 81 | if ( 82 | field.name in self.model._meta.fetch_fields 83 | and field.name not in self.model._meta.fk_fields | self.model._meta.m2m_fields 84 | ): 85 | continue 86 | fields.append(field) 87 | fields.insert(0, self._create_field_by_field_name(self._pk_column_name)) 88 | return fields 89 | 90 | def _get_column_by_name(self, name: str) -> Any: 91 | return self.model._meta.fields_map.get(name) 92 | 93 | def _convert_column_for_which_no_converter_found(self, column: TortoiseField, field_name: str) -> Field: 94 | placeholder = column.description or "" 95 | return Field( 96 | display=displays.Display(), 97 | input_=inputs.Input( 98 | placeholder=placeholder, null=column.null, default=column.default 99 | ), 100 | name=field_name, 101 | label=field_name.title() 102 | ) 103 | -------------------------------------------------------------------------------- /fastapi_admin2/i18n/locales/zh/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | # Chinese (Simplified, China) translations for PROJECT. 2 | # Copyright (C) 2021 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2021. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 10 | "POT-Creation-Date: 2021-08-06 23:07+0800\n" 11 | "PO-Revision-Date: 2021-04-16 22:46+0800\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language: zh_Hans_CN\n" 14 | "Language-Team: zh_Hans_CN \n" 15 | "Plural-Forms: nplurals=1; plural=0\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.9.1\n" 20 | 21 | #: fastapi_admin/resources.py:91 22 | msgid "create" 23 | msgstr "创建" 24 | 25 | #: fastapi_admin/resources.py:112 26 | msgid "update" 27 | msgstr "编辑" 28 | 29 | #: fastapi_admin/resources.py:114 30 | msgid "delete" 31 | msgstr "删除" 32 | 33 | #: fastapi_admin/resources.py:120 34 | msgid "delete_selected" 35 | msgstr "删除选中" 36 | 37 | #: fastapi_admin/providers/login.py:90 38 | msgid "login_failed" 39 | msgstr "登录失败" 40 | 41 | #: fastapi_admin/providers/login.py:161 42 | msgid "confirm_password_different" 43 | msgstr "密码不一致" 44 | 45 | #: fastapi_admin/providers/login.py:196 46 | msgid "old_password_error" 47 | msgstr "旧密码错误" 48 | 49 | #: fastapi_admin/providers/login.py:198 50 | msgid "new_password_different" 51 | msgstr "新密码不一致" 52 | 53 | #: fastapi_admin/templates/create.html:14 54 | #: fastapi_admin/templates/update.html:16 55 | msgid "save" 56 | msgstr "保存" 57 | 58 | #: fastapi_admin/templates/create.html:16 59 | msgid "save_and_add_another" 60 | msgstr "保存并新增" 61 | 62 | #: fastapi_admin/templates/create.html:18 63 | #: fastapi_admin/templates/update.html:29 64 | msgid "return" 65 | msgstr "返回" 66 | 67 | #: fastapi_admin/templates/init.html:10 68 | msgid "Create first admin" 69 | msgstr "" 70 | 71 | #: fastapi_admin/templates/init.html:12 72 | #: fastapi_admin/templates/providers/login/login.html:44 73 | msgid "username" 74 | msgstr "用户名" 75 | 76 | #: fastapi_admin/templates/init.html:14 77 | msgid "username_placeholder" 78 | msgstr "请输入用户名" 79 | 80 | #: fastapi_admin/templates/init.html:18 81 | #: fastapi_admin/templates/providers/login/login.html:54 82 | msgid "password" 83 | msgstr "密码" 84 | 85 | #: fastapi_admin/templates/init.html:23 86 | msgid "password_placeholder" 87 | msgstr "请输入密码" 88 | 89 | #: fastapi_admin/templates/init.html:31 90 | msgid "confirm_password" 91 | msgstr "请确认密码" 92 | 93 | #: fastapi_admin/templates/init.html:36 94 | msgid "confirm_password_placeholder" 95 | msgstr "请再次输入密码" 96 | 97 | #: fastapi_admin/templates/init.html:43 98 | #: fastapi_admin/templates/providers/login/password.html:32 99 | msgid "submit" 100 | msgstr "提交" 101 | 102 | #: fastapi_admin/templates/list.html:13 103 | msgid "show" 104 | msgstr "显示" 105 | 106 | #: fastapi_admin/templates/list.html:24 107 | msgid "entries" 108 | msgstr "项" 109 | 110 | #: fastapi_admin/templates/list.html:33 111 | msgid "search" 112 | msgstr "查询" 113 | 114 | #: fastapi_admin/templates/list.html:46 115 | msgid "bulk_actions" 116 | msgstr "批量操作" 117 | 118 | #: fastapi_admin/templates/list.html:125 119 | msgid "actions" 120 | msgstr "动作" 121 | 122 | #: fastapi_admin/templates/list.html:156 123 | #, python-format 124 | msgid "Showing %(from)s to %(to)s of %(total)s entries" 125 | msgstr "显示 %(from)s 到 %(to)s 共 %(total)s 项" 126 | 127 | #: fastapi_admin/templates/list.html:167 128 | msgid "prev_page" 129 | msgstr "上一页" 130 | 131 | #: fastapi_admin/templates/list.html:188 132 | msgid "next_page" 133 | msgstr "下一页" 134 | 135 | #: fastapi_admin/templates/update.html:23 136 | msgid "save_and_return" 137 | msgstr "保存并返回" 138 | 139 | #: fastapi_admin/templates/errors/403.html:21 140 | #: fastapi_admin/templates/errors/404.html:21 141 | #: fastapi_admin/templates/errors/500.html:21 142 | msgid "return_home" 143 | msgstr "返回首页" 144 | 145 | #: fastapi_admin/templates/providers/login/avatar.html:18 146 | #: fastapi_admin/templates/providers/login/password.html:6 147 | msgid "update_password" 148 | msgstr "修改密码" 149 | 150 | #: fastapi_admin/templates/providers/login/avatar.html:24 151 | msgid "logout" 152 | msgstr "退出登录" 153 | 154 | #: fastapi_admin/templates/providers/login/login.html:49 155 | msgid "login_username_placeholder" 156 | msgstr "请输入用户名" 157 | 158 | #: fastapi_admin/templates/providers/login/login.html:59 159 | msgid "login_password_placeholder" 160 | msgstr "请输入密码" 161 | 162 | #: fastapi_admin/templates/providers/login/login.html:75 163 | msgid "remember_me" 164 | msgstr "记住我" 165 | 166 | #: fastapi_admin/templates/providers/login/login.html:80 167 | msgid "sign_in" 168 | msgstr "登录" 169 | 170 | #: fastapi_admin/templates/providers/login/password.html:12 171 | msgid "old_password" 172 | msgstr "旧密码" 173 | 174 | #: fastapi_admin/templates/providers/login/password.html:15 175 | msgid "old_password_placeholder" 176 | msgstr "请输入旧密码" 177 | 178 | #: fastapi_admin/templates/providers/login/password.html:19 179 | msgid "new_password" 180 | msgstr "新密码" 181 | 182 | #: fastapi_admin/templates/providers/login/password.html:22 183 | msgid "new_password_placeholder" 184 | msgstr "请输入新密码" 185 | 186 | #: fastapi_admin/templates/providers/login/password.html:26 187 | msgid "re_new_password" 188 | msgstr "确认新密码" 189 | 190 | #: fastapi_admin/templates/providers/login/password.html:29 191 | msgid "re_new_password_placeholder" 192 | msgstr "请再次输入新密码" 193 | -------------------------------------------------------------------------------- /fastapi_admin2/i18n/locales/en/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | # English (United States) translations for PROJECT. 2 | # Copyright (C) 2021 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2021. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 10 | "POT-Creation-DateRange: 2021-08-06 23:07+0800\n" 11 | "PO-Revision-DateRange: 2021-04-16 22:46+0800\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language: en_US\n" 14 | "Language-Team: en_US \n" 15 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.9.1\n" 20 | 21 | #: fastapi_admin/resources.py:91 22 | msgid "create" 23 | msgstr "Create" 24 | 25 | #: fastapi_admin/resources.py:112 26 | msgid "update" 27 | msgstr "Update" 28 | 29 | #: fastapi_admin/resources.py:114 30 | msgid "delete" 31 | msgstr "Delete" 32 | 33 | #: fastapi_admin/resources.py:120 34 | msgid "delete_selected" 35 | msgstr "Delete Selected" 36 | 37 | #: fastapi_admin/providers/login/login.html:41 38 | msgid "login_title" 39 | msgstr "Login to your account" 40 | 41 | 42 | #: fastapi_admin/providers/login.py:90 43 | msgid "login_failed" 44 | msgstr "Login to your account failed" 45 | 46 | #: fastapi_admin/providers/login.py:161 47 | msgid "confirm_password_different" 48 | msgstr "Password is different" 49 | 50 | #: fastapi_admin/providers/login.py:196 51 | msgid "old_password_error" 52 | msgstr "Old password error" 53 | 54 | #: fastapi_admin/providers/login.py:198 55 | msgid "new_password_different" 56 | msgstr "New password is different" 57 | 58 | #: fastapi_admin/templates/create.html:14 59 | #: fastapi_admin/templates/update.html:16 60 | msgid "save" 61 | msgstr "Save" 62 | 63 | #: fastapi_admin/templates/create.html:16 64 | msgid "save_and_add_another" 65 | msgstr "Save and add another" 66 | 67 | #: fastapi_admin/templates/create.html:18 68 | #: fastapi_admin/templates/update.html:29 69 | msgid "return" 70 | msgstr "Return" 71 | 72 | #: fastapi_admin/templates/init.html:10 73 | msgid "Create first admin" 74 | msgstr "" 75 | 76 | #: fastapi_admin/templates/init.html:12 77 | #: fastapi_admin/templates/providers/login/login.html:44 78 | msgid "username" 79 | msgstr "Username" 80 | 81 | #: fastapi_admin/templates/init.html:14 82 | msgid "username_placeholder" 83 | msgstr "Enter username" 84 | 85 | #: fastapi_admin/templates/init.html:18 86 | #: fastapi_admin/templates/providers/login/login.html:54 87 | msgid "renew_password" 88 | msgstr "Password" 89 | 90 | #: fastapi_admin/templates/init.html:23 91 | msgid "password_placeholder" 92 | msgstr "Enter password" 93 | 94 | #: fastapi_admin/templates/init.html:31 95 | msgid "confirm_password" 96 | msgstr "Enter password again" 97 | 98 | #: fastapi_admin/templates/init.html:36 99 | msgid "confirm_password_placeholder" 100 | msgstr "Enter password again" 101 | 102 | #: fastapi_admin/templates/init.html:43 103 | #: fastapi_admin/templates/providers/login/password.html:32 104 | msgid "submit" 105 | msgstr "Submit" 106 | 107 | 108 | #: fastapi_admin/templates/init.html:43 109 | msgid "Avatar" 110 | msgstr "" 111 | 112 | #: fastapi_admin/templates/list.html:13 113 | msgid "show" 114 | msgstr "Show" 115 | 116 | #: fastapi_admin/templates/list.html:24 117 | msgid "entries" 118 | msgstr "" 119 | 120 | #: fastapi_admin/templates/list.html:33 121 | msgid "search" 122 | msgstr "Search" 123 | 124 | #: fastapi_admin/templates/list.html:46 125 | msgid "bulk_actions" 126 | msgstr "Bulk Actions" 127 | 128 | #: fastapi_admin/templates/list.html:125 129 | msgid "actions" 130 | msgstr "Actions" 131 | 132 | #: fastapi_admin/templates/list.html:156 133 | #, python-format 134 | msgid "Showing %(from)s to %(to)s of %(total)s entries" 135 | msgstr "" 136 | 137 | #: fastapi_admin/templates/list.html:167 138 | msgid "prev_page" 139 | msgstr "Prev" 140 | 141 | #: fastapi_admin/templates/list.html:188 142 | msgid "next_page" 143 | msgstr "Next" 144 | 145 | #: fastapi_admin/templates/update.html:23 146 | msgid "save_and_return" 147 | msgstr "Save and return" 148 | 149 | #: fastapi_admin/templates/errors/403.html:21 150 | #: fastapi_admin/templates/errors/404.html:21 151 | #: fastapi_admin/templates/errors/500.html:21 152 | msgid "return_home" 153 | msgstr "Take me home" 154 | 155 | #: fastapi_admin/templates/providers/login/avatar.html:18 156 | #: fastapi_admin/templates/providers/login/password.html:6 157 | msgid "update_password" 158 | msgstr "Update password" 159 | 160 | #: fastapi_admin/templates/providers/login/avatar.html:24 161 | msgid "logout" 162 | msgstr "Logout" 163 | 164 | #: fastapi_admin/templates/providers/login/login.html:49 165 | msgid "login_username_placeholder" 166 | msgstr "Enter username" 167 | 168 | #: fastapi_admin/templates/providers/login/login.html:59 169 | msgid "login_password_placeholder" 170 | msgstr "Enter password" 171 | 172 | #: fastapi_admin/templates/providers/login/login.html:75 173 | msgid "remember_me" 174 | msgstr "Remember me" 175 | 176 | #: fastapi_admin/templates/providers/login/login.html:80 177 | msgid "sign_in" 178 | msgstr "Sign in" 179 | 180 | #: fastapi_admin/templates/providers/login/password.html:12 181 | msgid "old_password" 182 | msgstr "Old password" 183 | 184 | #: fastapi_admin/templates/providers/login/password.html:15 185 | msgid "old_password_placeholder" 186 | msgstr "Enter old password" 187 | 188 | #: fastapi_admin/templates/providers/login/password.html:19 189 | msgid "new_password" 190 | msgstr "New password" 191 | 192 | #: fastapi_admin/templates/providers/login/password.html:22 193 | msgid "new_password_placeholder" 194 | msgstr "Enter new password" 195 | 196 | #: fastapi_admin/templates/providers/login/password.html:26 197 | msgid "re_new_password" 198 | msgstr "Confirm new password" 199 | 200 | #: fastapi_admin/templates/providers/login/password.html:29 201 | msgid "re_new_password_placeholder" 202 | msgstr "Enter new password again" 203 | 204 | -------------------------------------------------------------------------------- /fastapi_admin2/ui/widgets/filters.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from dataclasses import dataclass 3 | from enum import Enum as EnumCLS 4 | from typing import Any, List, Tuple, Type, TypeVar, ClassVar, Sequence 5 | 6 | import pendulum 7 | from starlette.requests import Request 8 | 9 | from fastapi_admin2.default_settings import DATE_FORMAT_MOMENT 10 | from fastapi_admin2.ui.widgets.exceptions import FilterValidationError 11 | 12 | Q = TypeVar("Q") 13 | 14 | 15 | @dataclass 16 | class DateRangeDTO: 17 | start: pendulum.DateTime 18 | end: pendulum.DateTime 19 | 20 | def to_string(self, date_format: str) -> str: 21 | return f"{self.start.format(date_format)} - {self.end.format(date_format)}" 22 | 23 | 24 | class AbstractFilter(abc.ABC): 25 | template_name: ClassVar[str] = "" 26 | 27 | def __init__( 28 | self, 29 | name: str, 30 | placeholder: str = "", 31 | null: bool = True, 32 | **additional_context: Any 33 | ) -> None: 34 | self.name = name 35 | self._placeholder = placeholder 36 | self._null = null 37 | self._ctx = additional_context 38 | 39 | async def render(self, request: Request) -> str: 40 | current_filter_value = request.query_params.get(self.name) 41 | if current_filter_value is None: 42 | current_filter_value = "" 43 | if not self.template_name: 44 | return current_filter_value 45 | 46 | return await request.state.render_jinja( 47 | self.template_name, 48 | context=dict( 49 | current_locale=request.state.current_locale, 50 | name=self.name, 51 | placeholder=self._placeholder, 52 | null=self._null, 53 | value=current_filter_value, 54 | **self._ctx 55 | ) 56 | ) 57 | 58 | def apply(self, query: Q, value: Any) -> Q: 59 | try: 60 | self.validate(value) 61 | except FilterValidationError: 62 | return query 63 | 64 | return self._apply_to_sql_query(query, self.clean(value)) 65 | 66 | @abc.abstractmethod 67 | def _apply_to_sql_query(self, query: Q, value: Any) -> Q: 68 | pass 69 | 70 | def validate(self, value: Any) -> None: 71 | """ 72 | Validates input value 73 | 74 | :param value: 75 | :raises: 76 | FilterValidationError if validation is not succeed 77 | """ 78 | if not value: 79 | raise FilterValidationError( 80 | f"{self.__class__.__qualname__} filter's validation has been failed" 81 | ) 82 | 83 | def clean(self, value: Any) -> Any: 84 | return value 85 | 86 | 87 | class BaseSearchFilter(AbstractFilter, abc.ABC): 88 | template_name = "widgets/filters/search.html" 89 | 90 | 91 | class BaseDateRangeFilter(AbstractFilter, abc.ABC): 92 | template_name = "widgets/filters/datetime.html" 93 | 94 | def __init__( 95 | self, 96 | name: str, 97 | date_format: str = DATE_FORMAT_MOMENT, 98 | placeholder: str = "", 99 | null: bool = True, 100 | **additional_context: Any 101 | ): 102 | super().__init__(name, placeholder, null, **additional_context) 103 | self._ctx.update(date=True) 104 | self._date_format = date_format 105 | 106 | def clean(self, value: Any) -> Any: 107 | value = super().clean(value) 108 | date_range = value.split(" - ") 109 | return DateRangeDTO(start=pendulum.parse(date_range[0]), end=pendulum.parse(date_range[1])) 110 | 111 | 112 | class BaseDateTimeRangeFilter(BaseDateRangeFilter, abc.ABC): 113 | 114 | def __init__( 115 | self, 116 | name: str, 117 | date_format: str = DATE_FORMAT_MOMENT, 118 | placeholder: str = "", 119 | null: bool = True, 120 | **additional_context: Any 121 | ): 122 | super().__init__(name, date_format, placeholder, null, **additional_context) 123 | self._ctx.update(date=False) 124 | 125 | 126 | class BaseSelectFilter(AbstractFilter, abc.ABC): 127 | template_name: ClassVar[str] = "widgets/filters/select.html" 128 | 129 | async def render(self, request: Request) -> str: 130 | options = await self.get_options(request) 131 | self._ctx.update(options=options) 132 | return await super().render(request) 133 | 134 | @abc.abstractmethod 135 | async def get_options(self, request: Request) -> Sequence[Tuple[str, Any]]: 136 | """ 137 | return list of tuple with display and value 138 | 139 | [("on",1),("off",2)] 140 | 141 | :return: list of tuple with display and value 142 | """ 143 | 144 | 145 | class BaseEnumFilter(BaseSelectFilter, abc.ABC): 146 | 147 | def __init__( 148 | self, 149 | enum: Type[EnumCLS], 150 | name: str, 151 | enum_type: Type[Any] = int, 152 | placeholder: str = "", 153 | null: bool = True, 154 | **additional_context: Any 155 | ) -> None: 156 | super().__init__(placeholder=placeholder, name=name, null=null, **additional_context) 157 | self._enum = enum 158 | self._enum_type = enum_type 159 | 160 | async def clean(self, value: Any) -> EnumCLS: 161 | return self._enum(self._enum_type(value)) 162 | 163 | async def get_options(self, request: Request): 164 | options = [(v.name, v.value) for v in self._enum] 165 | if self._ctx.get("null"): 166 | options = [("", "")] + options 167 | return options 168 | 169 | 170 | class BaseBooleanFilter(BaseSelectFilter, abc.ABC): 171 | 172 | async def get_options(self, request: Request) -> List[Tuple[str, str]]: 173 | """Return list of possible values to select from.""" 174 | options = [ 175 | (request.state.gettext("TRUE"), "true"), 176 | (request.state.gettext("FALSE"), "false"), 177 | ] 178 | if self._null: 179 | options.insert(0, ("", "")) 180 | 181 | return options 182 | -------------------------------------------------------------------------------- /fastapi_admin2/backends/sqla/toolings.py: -------------------------------------------------------------------------------- 1 | from operator import eq 2 | from typing import no_type_check, Tuple, Optional, Any, Iterable, TypeVar, Union, List 3 | 4 | from sqlalchemy import inspect, ForeignKey, Column, tuple_, and_, or_ 5 | from sqlalchemy.ext.associationproxy import ASSOCIATION_PROXY 6 | from sqlalchemy.orm import RelationshipProperty, MapperProperty 7 | from sqlalchemy.sql.elements import BooleanClauseList 8 | 9 | 10 | def parse_like_term(term: str) -> str: 11 | if term.startswith('^'): 12 | stmt = '%s%%' % term[1:] 13 | elif term.startswith('='): 14 | stmt = term[1:] 15 | else: 16 | stmt = '%%%s%%' % term 17 | 18 | return stmt 19 | 20 | 21 | @no_type_check 22 | def get_primary_key(model): 23 | """ 24 | Return primary key name from a model. If the primary key consists of multiple columns, 25 | return the corresponding tuple 26 | :param model: 27 | Model class 28 | """ 29 | mapper = model._sa_class_manager.mapper 30 | pks = [mapper.get_property_by_column(c).key for c in mapper.primary_key] 31 | if len(pks) == 1: 32 | return pks[0] 33 | elif len(pks) > 1: 34 | return tuple(pks) 35 | else: 36 | return None 37 | 38 | 39 | @no_type_check 40 | def get_related_querier_from_model_by_foreign_key(column): 41 | """ 42 | Return querier for related model 43 | 44 | :param column: local column with foreign key 45 | :return: 46 | """ 47 | model = inspect(column).class_ 48 | column_foreign_key: ForeignKey = next(iter(column.foreign_keys)) 49 | relationships: Tuple[RelationshipProperty, ...] = tuple(inspect(model).relationships) 50 | relation_for_foreign_key = _find_relation_for_foreign_key(relationships, column) 51 | querier: Optional[Any] = None 52 | if relation_for_foreign_key is not None: 53 | querier_table = relation_for_foreign_key.target 54 | else: 55 | querier_table = column_foreign_key.constraint.referred_table 56 | 57 | for mapper in model._sa_class_manager.registry.mappers: 58 | if mapper.persist_selectable == querier_table: 59 | querier = mapper.class_ 60 | if querier is None: 61 | raise Exception("Unable to find table to which mapped foreign key/relationship") 62 | return querier 63 | 64 | 65 | def _resolve_prop(prop: MapperProperty) -> MapperProperty: 66 | """ 67 | Resolve proxied property 68 | :param prop: 69 | Property to resolve 70 | """ 71 | # Try to see if it is proxied property 72 | if hasattr(prop, '_proxied_property'): 73 | return prop._proxied_property 74 | 75 | return prop 76 | 77 | 78 | def _find_relation_for_foreign_key(relationships: Iterable[RelationshipProperty], 79 | local_foreign_key_column: Any) -> Optional[RelationshipProperty]: 80 | for relationship in relationships: 81 | for pair in relationship.synchronize_pairs: 82 | for c in pair: 83 | if c == local_foreign_key_column: 84 | return relationship 85 | return None 86 | 87 | 88 | _S = TypeVar("_S", bound=Any) 89 | 90 | 91 | def include_where_condition_by_pk(statement: _S, model: Any, ids: Union[List[Any], Any], 92 | dialect_name: str) -> _S: 93 | """ 94 | Return a query object filtered by primary key values passed in `ids` argument. 95 | Unfortunately, it is not possible to use `in_` filter if model has more than one 96 | primary key. 97 | """ 98 | if has_multiple_pks(model): 99 | model_pk = [getattr(model, name) for name in get_primary_key(model)] 100 | else: 101 | model_pk = getattr(model, get_primary_key(model)) 102 | 103 | if isinstance(ids, str): 104 | if not has_multiple_pks(model): 105 | statement = statement.where(model_pk == ids) 106 | else: 107 | statement = statement.where( 108 | or_( 109 | *[pk == ids for pk in model_pk] 110 | ) 111 | ) 112 | else: 113 | if has_multiple_pks(model): 114 | if dialect_name != "sqlite": 115 | statement = statement.where(tuple_(*model_pk).in_(ids)) 116 | else: 117 | statement = statement.where(tuple_operator_in(model_pk, ids)) 118 | else: 119 | ids = map(model_pk.type.python_type, ids) # type: ignore 120 | statement = statement.where(model_pk.in_(ids)) # type: ignore 121 | 122 | return statement 123 | 124 | 125 | def has_multiple_pks(model: Any) -> bool: 126 | """ 127 | Return True, if the model has more than one primary key 128 | """ 129 | if not hasattr(model, '_sa_class_manager'): 130 | raise TypeError('model must be a sqlalchemy mapped model') 131 | 132 | return len(model._sa_class_manager.mapper.primary_key) > 1 133 | 134 | 135 | def tuple_operator_in(model_pk: List[Column], ids: List[Any]) -> Optional[BooleanClauseList]: 136 | """The tuple_ Operator only works on certain engines like MySQL or Postgresql. It does not work with sqlite. 137 | The function returns an or_ - operator, that contains and_ - operators for every single tuple in ids. 138 | Example:: 139 | model_pk = [ColumnA, ColumnB] 140 | ids = ((1,2), (1,3)) 141 | tuple_operator(model_pk, ids) -> or_( and_( ColumnA == 1, ColumnB == 2), and_( ColumnA == 1, ColumnB == 3) ) 142 | The returning operator can be used within a filter(), as it is just an or_ operator 143 | """ 144 | and_conditions = [] 145 | for id in ids: 146 | conditions = [] 147 | for i in range(len(model_pk)): 148 | conditions.append(eq(model_pk[i], id[i])) 149 | and_conditions.append(and_(*conditions)) 150 | if len(and_conditions) >= 1: 151 | return or_(*and_conditions) 152 | else: 153 | return None 154 | 155 | 156 | def is_relationship(attr: Any) -> bool: 157 | return hasattr(attr, 'property') and hasattr(attr.property, 'direction') 158 | 159 | 160 | def is_association_proxy(attr: Any) -> bool: 161 | if hasattr(attr, 'parent'): 162 | attr = attr.parent 163 | return hasattr(attr, 'extension_type') and attr.extension_type == ASSOCIATION_PROXY 164 | -------------------------------------------------------------------------------- /fastapi_admin2/i18n/locales/ru/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | # English (United States) translations for PROJECT. 2 | # Copyright (C) 2021 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2021. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 10 | "POT-Creation-DateRange: 2021-08-06 23:07+0800\n" 11 | "PO-Revision-DateRange: 2021-04-16 22:46+0800\n" 12 | "Last-Translator: Glib Garanin \n" 13 | "Language: ru_RU\n" 14 | "Language-Team: ru_RU \n" 15 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.9.1\n" 20 | 21 | #: fastapi_admin2/resources.py:91 22 | msgid "create" 23 | msgstr "Создать" 24 | 25 | #: fastapi_admin2/resources.py:112 26 | msgid "update" 27 | msgstr "Обновить" 28 | 29 | 30 | #: fastapi_admin2/templates/init.html:43 31 | msgid "Avatar" 32 | msgstr "Аватарка" 33 | 34 | #: fastapi_admin2/resources.py:114 35 | msgid "delete" 36 | msgstr "Удалить" 37 | 38 | #: fastapi_admin2/resources.py:120 39 | msgid "delete_selected" 40 | msgstr "Удалить выбранное" 41 | 42 | #: fastapi_admin2/providers/security/impl.py:90 43 | msgid "login_failed" 44 | msgstr "Не удалось войти в учетную запись" 45 | 46 | #: fastapi_admin2/providers/security/impl.py:161 47 | msgid "confirm_password_different" 48 | msgstr "Неправильный пароль" 49 | 50 | #: fastapi_admin2/providers/security/impl.py:196 51 | msgid "old_password_error" 52 | msgstr "Старый пароль неверный" 53 | 54 | #: fastapi_admin2/providers/security/impl.py:198 55 | msgid "new_password_different" 56 | msgstr "Пароли не совпадают" 57 | 58 | #: fastapi_admin2/templates/create.html:14 59 | #: fastapi_admin2/templates/update.html:16 60 | msgid "save" 61 | msgstr "Сохранить" 62 | 63 | 64 | #: fastapi_admin2/providers/login/login.html:41 65 | msgid "login_title" 66 | msgstr "Войдите в свой аккаунт" 67 | 68 | #: fastapi_admin2/templates/create.html:16 69 | msgid "save_and_add_another" 70 | msgstr "Сохранить и добавить другой" 71 | 72 | #: fastapi_admin2/templates/create.html:18 73 | #: fastapi_admin2/templates/update.html:29 74 | msgid "return" 75 | msgstr "Вернуться" 76 | 77 | #: fastapi_admin2/templates/init.html:10 78 | msgid "Create first admin" 79 | msgstr "Создание первого администратора" 80 | 81 | #: fastapi_admin2/templates/init.html:12 82 | #: fastapi_admin2/templates/providers/login/login.html:44 83 | msgid "username" 84 | msgstr "Никнейм" 85 | 86 | #: fastapi_admin2/templates/init.html:14 87 | msgid "username_placeholder" 88 | msgstr "Введите никнейм" 89 | 90 | #: fastapi_admin2/templates/init.html:18 91 | msgid "password" 92 | msgstr "Пароль" 93 | 94 | #: fastapi_admin2/templates/providers/login/login.html:54 95 | msgid "renew_password" 96 | msgstr "Пароль" 97 | 98 | #: fastapi_admin2/templates/init.html:23 99 | msgid "password_placeholder" 100 | msgstr "Введите пароль" 101 | 102 | #: fastapi_admin2/templates/init.html:31 103 | msgid "confirm_password" 104 | msgstr "Введите пароль повторно" 105 | 106 | #: fastapi_admin2/templates/init.html:36 107 | msgid "confirm_password_placeholder" 108 | msgstr "Введите пароль повторно" 109 | 110 | #: fastapi_admin2/templates/init.html:43 111 | #: fastapi_admin2/templates/providers/login/password.html:32 112 | msgid "submit" 113 | msgstr "Подтвердить" 114 | 115 | #: fastapi_admin2/templates/list.html:13 116 | msgid "show" 117 | msgstr "Показать" 118 | 119 | #: fastapi_admin2/templates/list.html:24 120 | msgid "entries" 121 | msgstr "Записей" 122 | 123 | #: fastapi_admin2/templates/list.html:33 124 | msgid "search" 125 | msgstr "Искать" 126 | 127 | #: fastapi_admin2/templates/list.html:46 128 | msgid "bulk_actions" 129 | msgstr "Массовые действия" 130 | 131 | #: fastapi_admin2/templates/list.html:125 132 | msgid "actions" 133 | msgstr "Действия" 134 | 135 | #: fastapi_admin2/templates/list.html:156 136 | #, python-format 137 | msgid "Showing %(from)s to %(to)s of %(total)s entries" 138 | msgstr "Показывать от %(from)s до %(to)s-ой записи из %(total)s" 139 | 140 | #: fastapi_admin2/templates/list.html:167 141 | msgid "prev_page" 142 | msgstr "Предыдущая" 143 | 144 | #: fastapi_admin2/templates/list.html:188 145 | msgid "next_page" 146 | msgstr "Следующая" 147 | 148 | #: fastapi_admin2/templates/update.html:23 149 | msgid "save_and_return" 150 | msgstr "Сохранить и вернуться" 151 | 152 | #: fastapi_admin2/templates/errors/403.html:21 153 | #: fastapi_admin2/templates/errors/404.html:21 154 | #: fastapi_admin2/templates/errors/500.html:21 155 | msgid "return_home" 156 | msgstr "На главную" 157 | 158 | #: fastapi_admin/templates/providers/login/avatar.html:18 159 | #: fastapi_admin/templates/providers/login/password.html:6 160 | msgid "update_password" 161 | msgstr "Обновить пароль" 162 | 163 | #: fastapi_admin/templates/providers/login/avatar.html:24 164 | msgid "logout" 165 | msgstr "Выйти" 166 | 167 | #: fastapi_admin/templates/providers/login/login.html:49 168 | msgid "login_username_placeholder" 169 | msgstr "Введите ваш никнейм" 170 | 171 | #: fastapi_admin/templates/providers/login/login.html:59 172 | msgid "login_password_placeholder" 173 | msgstr "Введите ваш пароль" 174 | 175 | #: fastapi_admin/templates/providers/login/login.html:75 176 | msgid "remember_me" 177 | msgstr "Запомнить меня" 178 | 179 | #: fastapi_admin/templates/providers/login/login.html:80 180 | msgid "sign_in" 181 | msgstr "Войти" 182 | 183 | #: fastapi_admin/templates/providers/login/password.html:12 184 | msgid "old_password" 185 | msgstr "Старый пароль" 186 | 187 | #: fastapi_admin/templates/providers/login/password.html:15 188 | msgid "old_password_placeholder" 189 | msgstr "Ввести старый пароль" 190 | 191 | #: fastapi_admin/templates/providers/login/password.html:19 192 | msgid "new_password" 193 | msgstr "Новый пароль" 194 | 195 | #: fastapi_admin/templates/providers/login/password.html:22 196 | msgid "new_password_placeholder" 197 | msgstr "Введите новый пароль" 198 | 199 | #: fastapi_admin/templates/providers/login/password.html:26 200 | msgid "re_new_password" 201 | msgstr "Подтвердить новый пароль" 202 | 203 | #: fastapi_admin/templates/providers/login/password.html:29 204 | msgid "re_new_password_placeholder" 205 | msgstr "Введите новый пароль снова" 206 | 207 | 208 | #: Filters 209 | msgid "TRUE" 210 | msgstr "Да" 211 | 212 | msgid "FALSE" 213 | msgstr "Нет" 214 | 215 | -------------------------------------------------------------------------------- /fastapi_admin2/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any, Callable, Coroutine, Dict, List, Optional, Sequence, Type, Union 3 | from typing import Protocol 4 | 5 | from fastapi import FastAPI 6 | from fastapi.datastructures import Default 7 | from fastapi.params import Depends 8 | from starlette.middleware import Middleware 9 | from starlette.requests import Request 10 | from starlette.responses import JSONResponse, Response 11 | from starlette.routing import BaseRoute 12 | from starlette.status import HTTP_403_FORBIDDEN, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND, \ 13 | HTTP_500_INTERNAL_SERVER_ERROR 14 | 15 | 16 | from fastapi_admin2.providers import Provider 17 | from fastapi_admin2.utils.templating import JinjaTemplates 18 | from .middlewares.i18n.base import AbstractI18nMiddleware 19 | from .middlewares.i18n.impl import I18nMiddleware 20 | from .middlewares.theme import theme_middleware 21 | from .middlewares.templating import create_template_middleware 22 | from .i18n.localizer import I18NLocalizer 23 | from .ui.resources import AbstractModelResource as ModelResource 24 | from .ui.resources import Dropdown 25 | from .ui.resources.base import Resource 26 | from fastapi_admin2.utils.responses import server_error_exception, not_found, forbidden, unauthorized 27 | from .controllers import resources 28 | 29 | 30 | class ORMBackend(Protocol): 31 | def configure(self, app: FastAPI) -> None: ... 32 | 33 | 34 | ORMModel = Any 35 | 36 | 37 | class FastAPIAdmin(FastAPI): 38 | 39 | def __init__( 40 | self, *, 41 | orm_backend: ORMBackend, 42 | login_logo_url: Optional[str] = None, 43 | add_custom_exception_handlers: bool = True, 44 | logo_url: Optional[str] = None, 45 | admin_path: str = "/admin", 46 | providers: Optional[List[Provider]] = None, 47 | favicon_url: Optional[str] = None, 48 | i18n_middleware_class: Optional[Type[AbstractI18nMiddleware]] = None, 49 | debug: bool = False, routes: Optional[List[BaseRoute]] = None, 50 | title: str = "FastAPI", 51 | description: str = "", 52 | version: str = "0.1.0", 53 | openapi_url: Optional[str] = "/openapi.json", 54 | openapi_tags: Optional[List[Dict[str, Any]]] = None, 55 | servers: Optional[List[Dict[str, Union[str, Any]]]] = None, 56 | dependencies: Optional[Sequence[Depends]] = None, 57 | default_response_class: Type[Response] = Default(JSONResponse), 58 | docs_url: Optional[str] = "/docs", 59 | redoc_url: Optional[str] = "/redoc", 60 | swagger_ui_oauth2_redirect_url: Optional[str] = "/docs/oauth2-redirect", 61 | swagger_ui_init_oauth: Optional[Dict[str, Any]] = None, 62 | middleware: Optional[Sequence[Middleware]] = None, 63 | exception_handlers: Optional[Dict[ 64 | Union[int, Type[Exception]], 65 | Callable[[Request, Any], Coroutine[Any, Any, Response]], 66 | ]] = None, 67 | on_startup: Optional[Sequence[Callable[[], Any]]] = None, 68 | on_shutdown: Optional[Sequence[Callable[[], Any]]] = None, 69 | terms_of_service: Optional[str] = None, contact: Optional[Dict[str, Union[str, Any]]] = None, 70 | license_info: Optional[Dict[str, Union[str, Any]]] = None, 71 | openapi_prefix: str = "", 72 | root_path: str = "", root_path_in_servers: bool = True, 73 | responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, 74 | callbacks: Optional[List[BaseRoute]] = None, deprecated: Optional[bool] = None, 75 | include_in_schema: bool = True, **extra: Any 76 | ) -> None: 77 | super().__init__( 78 | debug=debug, routes=routes, title=title, description=description, version=version, 79 | openapi_url=openapi_url, openapi_tags=openapi_tags, servers=servers, 80 | dependencies=dependencies, default_response_class=default_response_class, 81 | docs_url=docs_url, redoc_url=redoc_url, 82 | swagger_ui_oauth2_redirect_url=swagger_ui_oauth2_redirect_url, 83 | swagger_ui_init_oauth=swagger_ui_init_oauth, middleware=middleware, 84 | exception_handlers=exception_handlers, on_startup=on_startup, 85 | on_shutdown=on_shutdown, terms_of_service=terms_of_service, contact=contact, 86 | license_info=license_info, openapi_prefix=openapi_prefix, root_path=root_path, 87 | root_path_in_servers=root_path_in_servers, responses=responses, callbacks=callbacks, 88 | deprecated=deprecated, include_in_schema=include_in_schema, **extra 89 | ) 90 | 91 | self.admin_path = admin_path 92 | self.login_logo_url = login_logo_url 93 | self.admin_path = admin_path 94 | 95 | self.logo_url = logo_url 96 | self.favicon_url = favicon_url 97 | 98 | translator = I18NLocalizer() 99 | 100 | self.templates = JinjaTemplates() 101 | self.templates.env.add_extension("jinja2.ext.i18n") 102 | self.middleware("http")(create_template_middleware(self.templates)) 103 | self.dependency_overrides[JinjaTemplates] = lambda: self.templates 104 | 105 | if i18n_middleware_class is None: 106 | i18n_middleware_class = I18nMiddleware 107 | self.add_middleware(i18n_middleware_class, translator=translator) 108 | self.language_switch = True 109 | 110 | self.middleware('http')(theme_middleware) 111 | 112 | self._orm_backend = orm_backend 113 | self._orm_backend.configure(self) 114 | 115 | self.resources: List[Type[Resource]] = [] 116 | self.model_resources: Dict[Type[ORMModel], Type[Resource]] = {} 117 | 118 | if add_custom_exception_handlers: 119 | exception_handlers = { 120 | HTTP_500_INTERNAL_SERVER_ERROR: server_error_exception, 121 | HTTP_404_NOT_FOUND: not_found, 122 | HTTP_403_FORBIDDEN: forbidden, 123 | HTTP_401_UNAUTHORIZED: unauthorized 124 | } 125 | for http_status, h in exception_handlers.items(): 126 | self.add_exception_handler(http_status, h) 127 | 128 | for p in providers: 129 | self.register_provider(p) 130 | self.include_router(resources.router) 131 | 132 | def register_provider(self, provider: Provider) -> None: 133 | provider.register(self) 134 | 135 | def register_resource(self, resource: Type[Resource]) -> None: 136 | self._set_model_resource(resource) 137 | self.resources.append(resource) 138 | 139 | def _set_model_resource(self, resource: Type[Resource]) -> None: 140 | if issubclass(resource, ModelResource): 141 | self.model_resources[resource.model] = resource 142 | elif issubclass(resource, Dropdown): 143 | for r in resource.resources: 144 | self._set_model_resource(r) 145 | 146 | def get_model_resource_type(self, model: Type[ORMModel]) -> Optional[Type[Resource]]: 147 | return self.model_resources.get(model) 148 | 149 | def add_template_folder(self, folder: Union[str, os.PathLike]) -> None: 150 | self.templates.env.loader.searchpath.insert(0, folder) 151 | -------------------------------------------------------------------------------- /fastapi_admin2/controllers/resources.py: -------------------------------------------------------------------------------- 1 | from typing import Type, Any, List, Dict 2 | 3 | from fastapi import APIRouter, Depends, Path 4 | from jinja2 import TemplateNotFound 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | from starlette.requests import Request 7 | from starlette.responses import RedirectResponse, Response 8 | from starlette.status import HTTP_303_SEE_OTHER 9 | 10 | from fastapi_admin2.entities import ResourceList 11 | from fastapi_admin2.depends import get_orm_model_by_resource_name, get_model_resource, get_resources 12 | from fastapi_admin2.backends.sqla.markers import AsyncSessionDependencyMarker 13 | from fastapi_admin2.ui.resources import AbstractModelResource 14 | from fastapi_admin2.utils.responses import redirect 15 | from fastapi_admin2.controllers.dependencies import ModelListDependencyMarker, DeleteOneDependencyMarker, \ 16 | DeleteManyDependencyMarker 17 | 18 | router = APIRouter() 19 | 20 | 21 | @router.get("/{resource}/list") 22 | async def list_view( 23 | request: Request, 24 | resources: List[Dict[str, Any]] = Depends(get_resources), 25 | model_resource: AbstractModelResource = Depends(get_model_resource), 26 | resource_name: str = Path(..., alias="resource"), 27 | page_size: int = 10, 28 | page_num: int = 1, 29 | resource_list: ResourceList = Depends(ModelListDependencyMarker) 30 | ) -> Response: 31 | filters = await model_resource.render_filters(request) 32 | rendered_fields = await model_resource.render_fields(resource_list.models, request) 33 | 34 | context = { 35 | "request": request, 36 | "resources": resources, 37 | "fields_label": model_resource.get_field_labels(), 38 | "row_attributes": rendered_fields.row_attributes, 39 | "column_css_attributes": rendered_fields.column_css_attributes, 40 | "cell_css_attributes": rendered_fields.cell_css_attributes, 41 | "rendered_values": rendered_fields.rows, 42 | "filters": filters, 43 | "resource": resource_name, 44 | "model_resource": model_resource, 45 | "resource_label": model_resource.label, 46 | "page_size": page_size, 47 | "page_num": page_num, 48 | "total": resource_list.total_entries_count, 49 | "from": page_size * (page_num - 1) + 1, 50 | "to": page_size * page_num, 51 | "page_title": model_resource.page_title, 52 | "page_pre_title": model_resource.page_pre_title, 53 | } 54 | try: 55 | return await request.state.create_html_response( 56 | f"{resource_name}/list.html", 57 | context=context, 58 | ) 59 | except TemplateNotFound: 60 | return await request.state.create_html_response( 61 | "list.html", 62 | context=context, 63 | ) 64 | 65 | 66 | @router.post("/{resource_name}/update/{pk}") 67 | async def update(request: Request, resource_name: str = Path(...), pk: int = Path(...)): 68 | # TODO fill out this view 69 | return RedirectResponse(url=request.headers.get("referer"), status_code=HTTP_303_SEE_OTHER) 70 | 71 | 72 | @router.get("/{resource}/update/{id}") 73 | async def update_view( 74 | request: Request, 75 | resource: str = Path(...), 76 | id_: str = Path(..., alias="id"), 77 | model_resource: AbstractModelResource = Depends(get_model_resource), 78 | resources=Depends(get_resources), 79 | model=Depends(get_orm_model_by_resource_name), 80 | session: AsyncSession = Depends(AsyncSessionDependencyMarker) 81 | ): 82 | async with session.begin(): 83 | obj = await session.get(model, id_) 84 | 85 | inputs = await model_resource.render_inputs(obj) 86 | context = { 87 | "request": request, 88 | "resources": resources, 89 | "resource_label": model_resource.label, 90 | "resource": resource, 91 | "inputs": inputs, 92 | "pk": id_, 93 | "model_resource": model_resource, 94 | "page_title": model_resource.page_title, 95 | "page_pre_title": model_resource.page_pre_title, 96 | } 97 | try: 98 | return await request.state.create_html_response( 99 | f"{resource}/update.html", 100 | context=context, 101 | ) 102 | except TemplateNotFound: 103 | return await request.state.create_html_response( 104 | "update.html", 105 | context=context, 106 | ) 107 | 108 | 109 | @router.get("/{resource}/create") 110 | async def create_view( 111 | request: Request, 112 | resource: str = Path(...), 113 | resources=Depends(get_resources), 114 | model_resource: AbstractModelResource = Depends(get_model_resource), 115 | ): 116 | inputs = await model_resource.render_inputs(request) 117 | context = { 118 | "request": request, 119 | "resources": resources, 120 | "resource_label": model_resource.label, 121 | "resource": resource, 122 | "inputs": inputs, 123 | "model_resource": model_resource, 124 | "page_title": model_resource.page_title, 125 | "page_pre_title": model_resource.page_pre_title, 126 | } 127 | try: 128 | return await request.state.create_html_response( 129 | f"{resource}/create.html", 130 | context=context, 131 | ) 132 | except TemplateNotFound: 133 | return await request.state.create_html_response( 134 | "create.html", 135 | context=context, 136 | ) 137 | 138 | 139 | @router.post("/{resource}/create") 140 | async def create( 141 | request: Request, 142 | resource: str = Path(...), 143 | resources=Depends(get_resources), 144 | model_resource: AbstractModelResource = Depends(get_model_resource), 145 | model: Type[Any] = Depends(get_orm_model_by_resource_name), 146 | session: AsyncSession = Depends(AsyncSessionDependencyMarker) 147 | ): 148 | inputs = await model_resource.render_inputs(request) 149 | form = await request.form() 150 | data, m2m_data = await model_resource.resolve_form_data(form) 151 | 152 | async with session.begin(): 153 | session.add(model(**data)) 154 | 155 | if "save" in form.keys(): 156 | return redirect(request, "list_view", resource=resource) 157 | context = { 158 | "request": request, 159 | "resources": resources, 160 | "resource_label": model_resource.label, 161 | "resource": resource, 162 | "inputs": inputs, 163 | "model_resource": model_resource, 164 | "page_title": model_resource.page_title, 165 | "page_pre_title": model_resource.page_pre_title, 166 | } 167 | try: 168 | return await request.state.create_html_response( 169 | f"{resource}/create.html", 170 | context=context, 171 | ) 172 | except TemplateNotFound: 173 | return await request.state.create_html_response( 174 | "create.html", 175 | context=context, 176 | ) 177 | 178 | 179 | @router.delete("/{resource}/delete/{id}", dependencies=[Depends(DeleteOneDependencyMarker)]) 180 | async def delete(request: Request): 181 | return RedirectResponse(url=request.headers.get("referer"), status_code=HTTP_303_SEE_OTHER) 182 | 183 | 184 | @router.delete("/{resource}/delete", dependencies=[Depends(DeleteManyDependencyMarker)]) 185 | async def bulk_delete(request: Request): 186 | return RedirectResponse(url=request.headers.get("referer"), status_code=HTTP_303_SEE_OTHER) 187 | -------------------------------------------------------------------------------- /fastapi_admin2/ui/widgets/inputs.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import json 3 | from enum import Enum as EnumCLS 4 | from typing import Any, List, Optional, Tuple, Type, Callable 5 | 6 | from starlette.datastructures import UploadFile 7 | from starlette.requests import Request 8 | 9 | from fastapi_admin2.default_settings import DATE_FORMAT_FLATPICKR 10 | from fastapi_admin2.utils.files import FileManager 11 | from fastapi_admin2.ui.widgets import Widget 12 | 13 | 14 | class Input(Widget): 15 | template_name = "widgets/inputs/input.html" 16 | 17 | def __init__( 18 | self, 19 | *validators: Callable[..., bool], 20 | help_text: Optional[str] = None, 21 | default: Any = None, 22 | null: bool = False, 23 | **context: Any 24 | ): 25 | super().__init__(null=null, help_text=help_text, **context) 26 | self.default = default 27 | self.validators = validators 28 | 29 | async def parse(self, value: Any): 30 | """ 31 | Parse value from frontend 32 | 33 | :param value: 34 | :return: 35 | """ 36 | 37 | return value 38 | 39 | async def render(self, request: Request, value: Any) -> str: 40 | if value is None: 41 | value = self.default 42 | 43 | return await super().render(request, value) 44 | 45 | 46 | class DisplayOnly(Input): 47 | """ 48 | Only display without input in edit or create 49 | """ 50 | 51 | 52 | class Text(Input): 53 | input_type = "text" 54 | 55 | def __init__( 56 | self, 57 | help_text: Optional[str] = None, 58 | default: Any = None, 59 | null: bool = False, 60 | placeholder: str = "", 61 | disabled: bool = False, 62 | **context: Any 63 | ): 64 | super().__init__( 65 | null=null, 66 | default=default, 67 | input_type=self.input_type, 68 | placeholder=placeholder, 69 | disabled=disabled, 70 | help_text=help_text, 71 | **context 72 | ) 73 | 74 | 75 | class Select(Input): 76 | template_name = "widgets/inputs/select.html" 77 | 78 | def __init__( 79 | self, 80 | help_text: Optional[str] = None, 81 | default: Any = None, 82 | null: bool = False, 83 | disabled: bool = False, 84 | ): 85 | super().__init__(help_text=help_text, null=null, default=default, disabled=disabled) 86 | 87 | @abc.abstractmethod 88 | async def get_options(self) -> List[Tuple[Any, ...]]: 89 | """ 90 | return list of tuple with display and value 91 | 92 | [("on",1),("off",2)] 93 | 94 | :return: list of tuple with display and value 95 | """ 96 | 97 | async def render(self, request: Request, value: Any) -> str: 98 | options = await self.get_options() 99 | self.context.update(options=options) 100 | return await super(Select, self).render(request, value) 101 | 102 | 103 | class BaseForeignKeyInput(Select, abc.ABC): 104 | def __init__( 105 | self, 106 | model: Any, 107 | default: Optional[Any] = None, 108 | null: bool = False, 109 | disabled: bool = False, 110 | help_text: Optional[str] = None, 111 | ): 112 | super().__init__(help_text=help_text, default=default, null=null, disabled=disabled) 113 | self.model = model 114 | 115 | 116 | class BaseManyToManyInput(BaseForeignKeyInput, abc.ABC): 117 | template_name = "widgets/inputs/many_to_many.html" 118 | 119 | 120 | class Enum(Select): 121 | def __init__( 122 | self, 123 | enum: Type[EnumCLS], 124 | default: Any = None, 125 | enum_type: Type = int, 126 | null: bool = False, 127 | disabled: bool = False, 128 | help_text: Optional[str] = None, 129 | ): 130 | super().__init__(help_text=help_text, default=default, null=null, disabled=disabled) 131 | self.enum = enum 132 | self.enum_type = enum_type 133 | 134 | async def parse(self, value: Any): 135 | return self.enum(self.enum_type(value)) 136 | 137 | async def get_options(self): 138 | options = [(v.name, v.value) for v in self.enum] 139 | if self.context.get("null"): 140 | options = [("", "")] + options 141 | return options 142 | 143 | 144 | class Email(Text): 145 | input_type = "email" 146 | 147 | 148 | class Json(Input): 149 | template_name = "widgets/inputs/json.html" 150 | 151 | def __init__( 152 | self, 153 | help_text: Optional[str] = None, 154 | null: bool = False, 155 | options: Optional[dict] = None, 156 | dumper: Callable[..., Any] = json.dumps 157 | ): 158 | """ 159 | options config to jsoneditor, see https://github.com/josdejong/jsoneditor 160 | 161 | :param options: 162 | """ 163 | super().__init__(null=null, help_text=help_text) 164 | if not options: 165 | options = {} 166 | self.context.update(options=options) 167 | self._dumper = dumper 168 | 169 | async def render(self, request: Request, value: Any): 170 | if value: 171 | value = self._dumper(value) 172 | return await super().render(request, value) 173 | 174 | 175 | class TextArea(Text): 176 | template_name = "widgets/inputs/textarea.html" 177 | input_type = "textarea" 178 | 179 | 180 | class Editor(Text): 181 | template_name = "widgets/inputs/editor.html" 182 | 183 | 184 | class DateTime(Text): 185 | input_type = "datetime" 186 | template_name = "widgets/inputs/datetime.html" 187 | 188 | def __init__( 189 | self, 190 | help_text: Optional[str] = None, 191 | default: Any = None, 192 | null: bool = False, 193 | placeholder: str = "", 194 | disabled: bool = False, 195 | ): 196 | super().__init__( 197 | null=null, 198 | default=default, 199 | placeholder=placeholder, 200 | disabled=disabled, 201 | help_text=help_text, 202 | enable_time=True 203 | ) 204 | 205 | 206 | class Date(Text): 207 | input_type = "date" 208 | 209 | def __init__( 210 | self, 211 | help_text: Optional[str] = None, 212 | default: Any = None, 213 | null: bool = False, 214 | placeholder: str = "", 215 | disabled: bool = False, 216 | format_: str = DATE_FORMAT_FLATPICKR 217 | ): 218 | super().__init__( 219 | null=null, 220 | default=default, 221 | placeholder=placeholder, 222 | disabled=disabled, 223 | help_text=help_text, 224 | enable_time=False, 225 | format=format_ 226 | ) 227 | 228 | 229 | class File(Input): 230 | input_type = "file" 231 | 232 | def __init__( 233 | self, 234 | file_manager: FileManager, 235 | default: Any = None, 236 | null: bool = False, 237 | disabled: bool = False, 238 | help_text: Optional[str] = None, 239 | ): 240 | super().__init__( 241 | null=null, 242 | default=default, 243 | input_type=self.input_type, 244 | disabled=disabled, 245 | help_text=help_text, 246 | ) 247 | self._file_manager = file_manager 248 | 249 | async def parse(self, value: Optional[UploadFile]): 250 | if value and value.filename: 251 | return await self._file_manager.download_file(value) 252 | return None 253 | 254 | 255 | class Image(File): 256 | template_name = "widgets/inputs/image.html" 257 | input_type = "file" 258 | 259 | 260 | class Radio(Select): 261 | template_name = "widgets/inputs/radio.html" 262 | 263 | def __init__( 264 | self, 265 | options: List[Tuple[str, Any]], 266 | help_text: Optional[str] = None, 267 | default: Any = None, 268 | disabled: bool = False, 269 | ): 270 | super().__init__(default=default, disabled=disabled, help_text=help_text) 271 | self.options = options 272 | 273 | async def get_options(self): 274 | return self.options 275 | 276 | 277 | class RadioEnum(Enum): 278 | template_name = "widgets/inputs/radio.html" 279 | 280 | 281 | class Switch(Input): 282 | template_name = "widgets/inputs/switch.html" 283 | 284 | async def parse(self, value: str): 285 | if value == "on": 286 | return True 287 | return False 288 | 289 | 290 | class Password(Text): 291 | input_type = "password" 292 | 293 | 294 | class Number(Text): 295 | input_type = "number" 296 | 297 | 298 | class Color(Text): 299 | template_name = "widgets/inputs/color.html" 300 | -------------------------------------------------------------------------------- /fastapi_admin2/providers/security/provider.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import TYPE_CHECKING, List, Optional 3 | 4 | from aioredis import Redis 5 | from fastapi import Depends, HTTPException 6 | from starlette.middleware.base import RequestResponseEndpoint, BaseHTTPMiddleware 7 | from starlette.requests import Request 8 | from starlette.responses import RedirectResponse, Response 9 | from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED 10 | 11 | from fastapi_admin2.depends import get_resources 12 | from fastapi_admin2.entities import AbstractAdmin 13 | from fastapi_admin2.providers import Provider 14 | from fastapi_admin2.providers.security.dependencies import AdminDaoDependencyMarker, EntityNotFound, \ 15 | AdminDaoProto 16 | from fastapi_admin2.providers.security.dto import InitAdmin, RenewPasswordCredentials, LoginCredentials 17 | from fastapi_admin2.providers.security.password_hashing.protocol import HashVerifyFailedError, \ 18 | PasswordHasherProto 19 | from fastapi_admin2.providers.security.responses import to_init_page, to_login_page 20 | from fastapi_admin2.utils.depends import get_dependency_from_request_by_marker 21 | from fastapi_admin2.utils.files import FileManager 22 | 23 | if TYPE_CHECKING: 24 | from fastapi_admin2.app import FastAPIAdmin 25 | 26 | 27 | def get_current_admin(request: Request) -> AbstractAdmin: 28 | admin = request.state.admin 29 | if not admin: 30 | raise HTTPException(status_code=HTTP_401_UNAUTHORIZED) 31 | return admin 32 | 33 | 34 | SESSION_ID_KEY = "user_session:{session_id}" 35 | 36 | 37 | class SecurityProvider(Provider): 38 | name = "security_provider" 39 | session_cookie_key = "user_session" 40 | 41 | def __init__( 42 | self, 43 | file_manager: FileManager, 44 | redis: Redis, 45 | password_hasher: PasswordHasherProto, 46 | login_path: str = "/login", 47 | logout_path: str = "/logout", 48 | login_page_template_name: str = "providers/login/login.html", 49 | login_logo_url: Optional[str] = None, 50 | login_title_translation_key: str = "login_title", 51 | keep_logined_in_seconds: int = 3600, 52 | keep_logined_with_checked_remember_me_in_seconds: int = 3600 * 24 * 7, 53 | ): 54 | self.login_path = login_path 55 | self.logout_path = logout_path 56 | self.template_name = login_page_template_name 57 | self.login_title_translation_key = login_title_translation_key 58 | self.login_logo_url = login_logo_url 59 | self._password_hasher = password_hasher 60 | self._file_manager = file_manager 61 | self._redis = redis 62 | self._keep_logined_with_checked_remember_me_in_seconds = keep_logined_with_checked_remember_me_in_seconds 63 | self._keep_logined_in_seconds = keep_logined_in_seconds 64 | 65 | def register(self, app: "FastAPIAdmin") -> None: 66 | super(SecurityProvider, self).register(app) 67 | 68 | app.get(self.login_path)(self.login_view) 69 | app.post(self.login_path)(self.login) 70 | app.get(self.logout_path)(self.logout) 71 | 72 | app.get("/init")(self.init_view) 73 | app.post("/init")(self.handle_creation_of_init_admin) 74 | 75 | app.get("/renew_password")(self.renew_password_view) 76 | app.post("/renew_password")(self.renew_password) 77 | 78 | app.add_middleware(BaseHTTPMiddleware, dispatch=self.authenticate_middleware) 79 | 80 | async def login_view(self, request: Request, 81 | admin_dao: AdminDaoProto = Depends(AdminDaoDependencyMarker), ) -> Response: 82 | if not await admin_dao.is_exists_at_least_one_admin(): 83 | return to_init_page(request) 84 | 85 | return await self.templates.create_html_response( 86 | self.template_name, 87 | context={ 88 | "request": request, 89 | "login_logo_url": self.login_logo_url, 90 | "login_title": self.login_title_translation_key, 91 | }, 92 | ) 93 | 94 | async def login( 95 | self, 96 | request: Request, 97 | login_credentials: LoginCredentials = Depends(LoginCredentials.as_form), 98 | admin_dao: AdminDaoProto = Depends(AdminDaoDependencyMarker), 99 | ) -> Response: 100 | unauthorized_response = await self.templates.create_html_response( 101 | self.template_name, 102 | status_code=HTTP_401_UNAUTHORIZED, 103 | context={"request": request, "error": request.state.gettext("login_failed")}, 104 | ) 105 | try: 106 | admin = await admin_dao.get_one_admin_by_filters(username=login_credentials.username) 107 | except EntityNotFound: 108 | return unauthorized_response 109 | else: 110 | if self._is_password_hash_is_invalid(admin, login_credentials.password): 111 | return unauthorized_response 112 | 113 | if self._password_hasher.is_rehashing_required(admin.password): 114 | await admin_dao.update_admin({}, password=self._password_hasher.hash(admin.password)) 115 | 116 | response = RedirectResponse(url=request.app.admin_path, status_code=HTTP_303_SEE_OTHER) 117 | if login_credentials.remember_me: 118 | expires_in_seconds = self._keep_logined_with_checked_remember_me_in_seconds 119 | response.set_cookie("remember_me", "on") 120 | else: 121 | expires_in_seconds = self._keep_logined_in_seconds 122 | response.delete_cookie("remember_me") 123 | 124 | session_id = uuid.uuid4().hex 125 | response.set_cookie( 126 | self.session_cookie_key, 127 | session_id, 128 | expires=expires_in_seconds, 129 | path=request.app.admin_path, 130 | httponly=True 131 | ) 132 | await self._redis.set(SESSION_ID_KEY.format(session_id=session_id), admin.id, ex=expires_in_seconds) 133 | return response 134 | 135 | async def logout(self, request: Request) -> Response: 136 | response = to_login_page(request) 137 | response.delete_cookie(self.session_cookie_key, path=request.app.admin_path) 138 | session_id = request.cookies[self.session_cookie_key] 139 | await self._redis.delete(SESSION_ID_KEY.format(session_id=session_id)) 140 | return response 141 | 142 | async def authenticate_middleware( 143 | self, 144 | request: Request, 145 | call_next: RequestResponseEndpoint 146 | ) -> Response: 147 | request.state.admin = None 148 | 149 | paths_related_to_authentication_stuff = [self.login_path, "/init", "/renew_password"] 150 | 151 | if not (session_id := request.cookies.get(self.session_cookie_key)): 152 | if request.scope["path"] not in paths_related_to_authentication_stuff: 153 | return to_login_page(request) 154 | 155 | admin_id = await self._redis.get(SESSION_ID_KEY.format(session_id=session_id)) 156 | admin_dao: AdminDaoProto = get_dependency_from_request_by_marker(request, AdminDaoDependencyMarker) 157 | try: 158 | admin = await admin_dao.get_one_admin_by_filters(id=int(admin_id)) 159 | except (EntityNotFound, TypeError): 160 | return await call_next(request) 161 | 162 | request.state.admin = admin 163 | return await call_next(request) 164 | 165 | async def init_view( 166 | self, 167 | request: Request, 168 | admin_dao: AdminDaoProto = Depends(AdminDaoDependencyMarker) 169 | ) -> Response: 170 | if await admin_dao.is_exists_at_least_one_admin(): 171 | return to_login_page(request) 172 | 173 | return await self.templates.create_html_response("init.html", context={"request": request}) 174 | 175 | async def handle_creation_of_init_admin( 176 | self, 177 | request: Request, 178 | init_admin: InitAdmin = Depends(InitAdmin.as_form), 179 | admin_dao: AdminDaoProto = Depends( 180 | AdminDaoDependencyMarker 181 | ) 182 | ): 183 | if await admin_dao.is_exists_at_least_one_admin(): 184 | return to_login_page(request) 185 | 186 | if init_admin.password != init_admin.confirm_password: 187 | return await self.templates.create_html_response( 188 | "init.html", 189 | context={"request": request, "error": request.state.gettext("confirm_password_different")}, 190 | ) 191 | 192 | path_to_profile_pic = await self._file_manager.download_file(init_admin.profile_pic) 193 | 194 | await admin_dao.add_admin( 195 | username=init_admin.username, 196 | password=self._password_hasher.hash(init_admin.password), 197 | profile_pic=str(path_to_profile_pic) 198 | ) 199 | 200 | return RedirectResponse(url=request.app.admin_path, status_code=HTTP_303_SEE_OTHER) 201 | 202 | async def renew_password_view( 203 | self, 204 | request: Request, 205 | resources=Depends(get_resources) 206 | ) -> Response: 207 | return await self.templates.create_html_response( 208 | "providers/login/renew_password.html", 209 | context={ 210 | "request": request, 211 | "resources": resources, 212 | }, 213 | ) 214 | 215 | async def renew_password( 216 | self, 217 | request: Request, 218 | form: RenewPasswordCredentials = Depends(RenewPasswordCredentials.as_form), 219 | admin: AbstractAdmin = Depends(get_current_admin), 220 | resources: List[dict] = Depends(get_resources), 221 | admin_dao: AdminDaoProto = Depends(AdminDaoDependencyMarker) 222 | ) -> Response: 223 | error = None 224 | if self._is_password_hash_is_invalid(admin, form.old_password): 225 | error = request.state.gettext("old_password_error") 226 | 227 | if form.new_password != form.confirmation_new_password: 228 | error = request.state.gettext("new_password_different") 229 | 230 | if error: 231 | return await self.templates.create_html_response( 232 | "renew_password.html", 233 | context={"request": request, "resources": resources, "error": error}, 234 | ) 235 | 236 | await admin_dao.update_admin({"id": admin.id}, password=form.new_password) 237 | return await self.logout(request) 238 | 239 | def _is_password_hash_is_invalid( 240 | self, 241 | admin: AbstractAdmin, 242 | password: str 243 | ) -> bool: 244 | try: 245 | self._password_hasher.verify(admin.password, password) 246 | except HashVerifyFailedError: 247 | return True 248 | 249 | return False 250 | --------------------------------------------------------------------------------