sqlalchey admin을 사용하여 관리자 페이지 만들기
저장소 : https://github.com/bluebamus/fastapi-sqlalchemy-admin-celery.git
공식 페이지 : https://aminalaee.dev/sqladmin/
참고 페이지 : https://puddingcamp.com/page/c83fd343-190e-4a60-b475-e14438a8978b
1. user databse 정의
class User(Base, BaseUser):
__tablename__ = "users"
id: Mapped[int] = mapped_column(
Integer, primary_key=True, nullable=False, autoincrement=True
)
username: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True
)
email: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True
)
hashed_password: Mapped[str] = mapped_column(String(60), nullable=False)
# age_level 컬럼을 Enum으로 정의
age_level_choices = ["10", "20", "30", "40", "50", "60", "70", "80"]
age_level: Mapped[str] = mapped_column(
Enum(*age_level_choices, name="age_level_enum"),
nullable=False,
default="10",
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
# One-to-many relationship with Post (DynamicMapped + lazy="dynamic")
posts_user: Mapped[list["Post"]] = relationship("Post", back_populates="user_posts")
# # Many-to-many relationship with Group
# user_groups: DynamicMapped["UserGroupAssociation"] = relationship(
# "UserGroupAssociation", back_populates="user", lazy="dynamic"
# )
# n:m 관계 (Group) – 최적의 lazy 옵션: selectin
group_user: Mapped[list["Group"]] = relationship(
"Group",
secondary="user_group_association",
back_populates="user_group",
lazy="selectin",
)
def __repr__(self) -> str:
return f"id={self.id}, username={self.username}"
@property
def is_authenticated(self) -> bool:
return self.is_active
@property
def display_name(self) -> str:
return self.username
@property
def identity(self) -> str:
return self.username
2. ModelView을 상속한 UserAdmin 정의
class UserAdmin(ModelView, model=User):
can_create = True
can_edit = True
can_delete = True
can_view_details = True
action_disallowed_list = []
name = "사용자"
name_plural = "사용자 목록"
icon = "fa-solid fa-user"
category = "사용자 관리"
# 목록 화면에서 표시할 모델 필드를 지정
# 모델 필드명을 문자열로 지정하는 게 아니라, 모델의 필드를 직접 전달해서 직관적
column_list = (
User.id,
User.username,
User.is_active,
)
# 검색할 때 검색 대상이 되는 모델 필드를 지정
# 기본적으로 검색어를 포함하는지(contains) 여부로 검색
column_searchable_list = (
User.id,
User.username,
)
# 정렬할 수 있는 모델 필드를 지정
column_sortable_list = (
User.id,
User.username,
# User.email,
)
# column_default_sort 는 정렬을 따로 지정하지 않으면 기본으로 정렬할 모델 필드를 가리키는데,
# 두 번째 값( True )은 역순으로 할 것인지 여부를 지정
# (User.id, True) 는 id 모델 필드에 대해 역순으로 정렬하겠다는 뜻
column_default_sort = (User.id, True)
column_formatters = {User.username: lambda m, a: m.username[:10]}
form_columns = (
"username",
"email",
"age_level",
"hashed_password",
"is_active",
)
page_size = 50
page_size_options = [25, 50, 100, 200]
def date_format(value: DateTime):
return value.strftime("%Y년 %m월 %d일 %H:%M") # 한국식 날짜 포맷
column_labels = {User.email: "Email"}
# 기존 타입 포맷터 유지 + date 타입 추가
column_type_formatters = dict(
ModelView.column_type_formatters, # 기본 포맷터 상속
date=date_format, # DateTime 타입 컬럼에 적용
)
save_as = True # 기존 객체를 기반으로 새로운 객체 저장 가능
async def insert_model(self, request: Request, data: dict) -> Any:
if _password := data.get("hashed_password"):
data["hashed_password"] = generate_password_hash(_password)
return await super().insert_model(request, data)
async def update_model(self, request: Request, pk: str, data: dict) -> Any:
if _password := data.get("hashed_password"):
obj = await self.get_object_for_details(pk)
if _password != obj.hashed_password:
data["hashed_password"] = generate_password_hash(_password)
return await super().update_model(request, pk, data)
async def on_model_change(
self, data: dict, model: User, is_created: bool, request: Request
) -> None:
pass
# if _password := data.get("hashed_password"):
# if _password != model.hashed_password:
# data["hashed_password"] = generate_password_hash(_password)
# async def on_model_change(
# self, data: dict, model: Todo, is_created: bool, request: Request
# ) -> None:
# for key, value in data.items():
# if not isinstance(value, datetime):
# continue
# if value.utcoffset() is not None:
# continue
# data[key] = value.astimezone(UTC)"""
2.1. 기본 파라미터 설명
- can_create: 모델이 SQLAdmin을 통해 새 인스턴스를 생성할 수 있는지 여부입니다. 기본값은 True이다.
- can_edit: 모델 인스턴스를 SQLAdmin을 통해 수정할 수 있는지 여부입니다. 기본값은 True이다.
- can_delete: 모델 인스턴스를 SQLAdmin을 통해 삭제할 수 있는지 여부입니다. 기본값은 True이다.
- can_view_details: 모델 인스턴스의 세부 정보를 SQLAdmin을 통해 볼 수 있는지 여부입니다. 기본값은 True이다.
- action_disallowed_list: 액션 기능 설정
액션 | 설명 |
"create" | 새 항목 생성 비활성화 |
"edit" | 기존 항목 수정 비활성화 |
"delete" | 삭제 기능 비활성화 |
"details" | 상세 보기 기능 비활성화 |
- name: 이 모델의 표시 이름이다. 기본값은 클래스 이름이다.
- name_plural: 이 모델의 복수형 표시 이름이다. 기본값은 클래스 이름 + s이다.
- icon: 관리자에서 이 모델에 표시할 아이콘이다. FontAwesome와 Tabler 이름만 지원된다.
- category: 드롭다운 메뉴에서 ModelView 클래스 그룹을 함께 표시할 카테고리 이름이다.
2.2. 리스트 페이지
- column_list: 목록 페이지에 표시할 열이나 열 이름의 리스트이다.
- column_exclude_list: 목록 페이지에서 제외할 열이나 열 이름의 리스트이다.
- column_formatters: 목록 페이지의 열 형식 지정기의 사전이다.
- column_searchable_list: 목록 페이지에서 검색 가능한 열이나 열 이름의 리스트이다.
- column_sortable_list: 목록 페이지에서 정렬 가능한 열이나 열 이름의 리스트이다.
- column_default_sort: 정렬이 적용되지 않았을 때의 기본 정렬이다. (열, 내림차순 여부)의 튜플 또는 여러 열에 대한 튜플의 리스트이다.
- list_query: 목록 쿼리를 사용자 정의할 수 있는 메서드이다. (request) -> stmt 형식을 따른다.
- count_query: 카운트 쿼리를 사용자 정의할 수 있는 메서드이다. (request) -> stmt 형식을 따른다.
- search_query: 검색 쿼리를 사용자 정의할 수 있는 메서드이다. (stmt, term) -> stmt 형식을 따른다.
- 특정한 모든 열을 수동으로 지정하지 않고도 column_list나 column_details_list에서 "all" 특수 키워드를 사용할 수 있다. 예를 들어: column_list = "all"
2.2. 상세 페이지
- 이 옵션들은 단일 User의 세부 정보를 볼 수 있는 상세 페이지(Details page) 설정을 구성하는 데 사용된다.
- 사용 가능한 옵션은 다음과 같다.
- column_details_list: 상세 페이지에서 표시할 컬럼 또는 컬럼 이름들의 리스트이다.
- column_details_exclude_list: 상세 페이지에서 제외할 컬럼 또는 컬럼 이름들의 리스트이다.
- column_formatters_detail: 상세 페이지에서 컬럼의 형식을 지정하는 딕셔너리이다.
2.3. 폼 옵션 (Form options)
- SQLAdmin은 모델과 함께 동작하는 폼을 사용자 정의할 수 있도록 지원한다.
- SQLAdmin의 폼은 WTForms 패키지를 기반으로 하며, 다음과 같은 옵션을 포함한다.
- form: 모델을 생성하거나 수정할 때 사용할 기본 폼. 기본값은 None이며, 폼은 동적으로 생성된다.
- form_base_class: 폼을 생성할 때 사용할 기본 클래스. 기본값은 wtforms.Form이다.
- form_args: WTForms에서 지원하는 폼 필드의 인자(Dictionary)이다.
- form_widget_args: WTForms에서 지원하는 위젯 렌더링 인자(Dictionary)이다.
- form_columns: 폼에 포함할 모델 컬럼 목록. 기본적으로 모든 모델 컬럼이 포함된다.
- form_excluded_columns: 폼에서 제외할 모델 컬럼 목록이다.
- form_overrides: 폼을 생성할 때 특정 필드를 재정의하는 Dictionary이다.
- form_include_pk: 기본 키(Primary Key) 컬럼을 생성/수정 폼에 포함할지 여부. 기본값은 False이다.
- form_ajax_refs: Select2를 사용하여 관계(Relationship) 모델을 비동기적으로 불러오도록 설정. 관련 모델에 많은 레코드가 있을 때 유용하다.
- form_converter: 추가적인 컬럼 타입을 지원하도록 사용자 정의 변환기(Converter)를 추가할 수 있다.
- form_edit_query: (request) -> stmt 형태의 메서드를 사용하여 수정 폼 데이터를 커스터마이징할 수 있다.
- form_rules: 폼의 렌더링 및 동작을 관리하는 규칙 목록이다.
- form_create_rules: 생성(Create) 페이지에서 폼의 렌더링 및 동작을 관리하는 규칙 목록이다.
- form_edit_rules: 수정(Edit) 페이지에서 폼의 렌더링 및 동작을 관리하는 규칙 목록이다.
form_columns = [User.name]
form_args = dict(name=dict(label="Full name"))
form_widget_args = dict(email=dict(readonly=True))
form_overrides = dict(email=wtforms.EmailField)
form_include_pk = True
form_ajax_refs = {
"user": {
"fields": ["username", "email"], # 검색 가능 필드
"order_by": "username", # 정렬 방식
"page_size": 20, # 한 번에 표시할 사용자 수
}
}
form_create_rules = ["name", "password"]
form_edit_rules = ["name"]
2.4. 리스트 페이지 네이션
- page_size: 페이지네이션의 기본 페이지 크기이다. 기본값은 10이다.
- page_size_options: 페이지네이션 선택기 옵션이다. 기본값은 [10, 25, 50, 100]이다.
2.5. 목록 페이지와 세부 페이지 모두에 적용되는 몇 가지 옵션
- column_labels: 컬럼 레이블 매핑으로, 모든 곳에서 컬럼 이름을 새 이름으로 매핑하는 데 사용된다.
- column_type_formatters: 모든 곳에서 형식을 지정하기 위한 타입 키와 호출 가능한 값의 매핑이다.
- 예를 들어, 목록 페이지와 상세 페이지 모두에서 사용할 사용자 지정 날짜 포맷터를 추가할 수 있다.
- save_as: 객체 편집 시 "새로 저장" 옵션을 활성화하는 불리언 값이다.
- save_as_continue: save_as가 활성화된 경우 리디렉션 URL을 제어하는 불리언 값이다.
2.6. save_as 옵션
- save_as 옵션은 기존 객체를 편집할 때 해당 객체의 복사본을 새로운 객체로 저장할 수 있는 기능을 제공한다. 이 옵션이 True로 설정되면, 편집 폼에 "새로 저장" 버튼이 추가된다.
- 사용자가 이 버튼을 클릭하면:
- 기존 객체는 변경되지 않고 유지된다.
- 편집 폼에 입력된 데이터로 새로운 객체가 생성된다.
- 이는 기존 객체를 템플릿으로 사용하여 유사한 객체를 빠르게 생성할 때 유용하다.
- save_as_continue 옵션
- save_as_continue 옵션은 save_as가 활성화된 상태에서 "새로 저장" 버튼을 클릭한 후 리디렉션 동작을 제어한다.
- True로 설정된 경우: 새로 생성된 객체의 편집 페이지로 리디렉션된다.
- False로 설정된 경우: 객체 목록 페이지로 리디렉션된다.
- 내보내기 옵션 (Export options)
- SQLAdmin은 목록(List) 페이지에서 데이터를 내보내는 기능을 지원한다.
- 현재 CSV 형식으로만 내보내기가 가능하며, 모델별로 다음과 같은 옵션을 설정할 수 있다.
- can_export: 해당 모델의 데이터를 내보낼 수 있는지 여부. 기본값은 True이다.
- column_export_list: 내보낼 데이터에 포함할 컬럼 목록. 기본값은 모든 모델 컬럼 포함한다.
- column_export_exclude_list: 내보낼 데이터에서 제외할 컬럼 목록이다.
- export_max_rows: 내보낼 최대 행(Row) 수. 기본값은 0이며, 이는 제한 없음(무제한)을 의미한다.
- export_types: 활성화할 내보내기 형식 목록. 기본값은 ["csv", "json"]이다.
2.7. 템플릿 (Templates)
- SQLAdmin의 템플릿 파일은 Jinja2를 기반으로 만들어졌으며, 설정을 통해 완전히 변경할 수 있다.
- 사용 가능한 페이지 템플릿은 다음과 같다.
- list_template: 모델 목록 페이지에서 사용할 템플릿. 기본값은 sqladmin/list.html.
- create_template: 모델 생성 페이지에서 사용할 템플릿. 기본값은 sqladmin/create.html.
- details_template: 모델 상세 페이지에서 사용할 템플릿. 기본값은 sqladmin/details.html.
- edit_template: 모델 수정 페이지에서 사용할 템플릿. 기본값은 sqladmin/edit.html.
- list_template = "custom_list.html"
- For more information about working with template see Working with Templates.
- url : https://aminalaee.dev/sqladmin/working_with_templates/
2.7. 이벤트 (Events)
- 모델이 생성(Create), 수정(Update), 삭제(Delete)되기 전이나 후에 특정 동작을 수행해야 하는 경우가 있을 수 있다.
- 이를 위해 다음 네 가지 메서드를 오버라이드(Override)하여 원하는 동작을 구현할 수 있다.
- on_model_change: 모델이 생성되거나 수정되기 전에 호출된다.
- after_model_change: 모델이 생성되거나 수정된 후에 호출된다.
- on_model_delete: 모델이 삭제되기 전에 호출된다.
- after_model_delete: 모델이 삭제된 후에 호출된다.
async def on_model_change(self, data, model, is_created, request):
# Perform some other action
...
async def on_model_delete(self, model, request):
# Perform some other action
...
2.8. 커스텀 액션 (Custom Action)
- Admin 패널에서 모델에 대한 사용자 지정 액션(Custom Action)을 추가하려면 @action 데코레이터를 사용할 수 있다.
- 사용 가능한 액션 옵션
- name: 이 액션의 URL에서 사용할 문자열 이름.
- label: 이 액션을 설명하는 문자열.
- add_in_list: 이 액션을 목록 페이지에서 사용할 수 있도록 할지 여부를 결정하는 불리언 값.
- add_in_detail: 이 액션을 상세 페이지에서 사용할 수 있도록 할지 여부를 결정하는 불리언 값.
- confirmation_message: 이 문자열 메시지가 정의되면, 액션 메서드를 호출하기 전에 확인 메시지를 표시하는 모달 창이 나타남.
from sqladmin import BaseView, action
class UserAdmin(ModelView, model=User):
@action(
name="approve_users",
label="Approve",
confirmation_message="Are you sure?",
add_in_detail=True,
add_in_list=True,
)
async def approve_users(self, request: Request):
pks = request.query_params.get("pks", "").split(",")
if pks:
for pk in pks:
model: User = await self.get_object_for_edit(pk)
...
referer = request.headers.get("Referer")
if referer:
return RedirectResponse(referer)
else:
return RedirectResponse(request.url_for("admin:list", identity=self.identity))
admin.add_view(UserAdmin)
- insert_model() 메서드는 request 객체와 data 객체를 인자로 받는다.
- request 는 Starlette의 Request 객체이다.
- data 는 사전형 객체인데, 폼에 입력된 값들이다.
- 여기에선 단순하게 사용자가 입력한 비밀번호가 있으면 해시해서 data 의 hashed_password 에 덮어씌운다.
async def insert_model(self, request: Request, data: dict) -> Any:
if _password := data.get("hashed_password"):
data["hashed_password"] = generate_password_hash(_password)
return await super().insert_model(request, data)
- 데이터 변경은 update_model() 메서드를 호출한다.
- insert_model() 메서드와 다른 점은 변경 대상의 기본키값도 인자로 전달해주는 것이다.
async def update_model(self, request: Request, pk: str, data: dict) -> Any:
if _password := data.get("hashed_password"):
obj = await self.get_object_for_details(pk)
if _password != obj.hashed_password:
data["hashed_password"] = generate_password_hash(_password)
return await super().update_model(request, pk, data)
- 차이점은 변경할 때 비밀번호를 입력하면 변경 대상의 데이터를 가져와서 해시 처리되어 저장된 값과 비교하고,
다르면 폼 데이터(payload인 data)에 새로 해시 처리한 문자열을 담는 것이다.
- 동일하다는 얘기는 비밀번호를 수정하지 않아서 해시 처리된 문자열 그대로 넘어온 거니까 해시 처리하면 안된다.
- 계정 비밀번호 해시 처리는 더 간결하고 안전한 방법이 있다.
- 바로 on_model_change() 메서드를 오버라이드하는 것이다.
- 이 메서드는 모델 데이터가 변경될 때 호출되는데, 추가(insert)와 변경(update)될 때 호출된다.
async def on_model_change(
self, data: dict, model: User, is_created: bool, request: Request
) -> None:
pass
if _password := data.get("hashed_password"):
if _password != model.hashed_password:
data["hashed_password"] = generate_password_hash(_password)
- 동작 순서
1. on_model_change() 메서드 호출
2. 실제 추가/변경 처리
3. after_model_change() 메서드 호출
- SQLAdmin은 Naive DateTime으로 일시 정보를 생성한다.
- 만약, 다른 시간타입을 사용한다면 on_model_change()으로 수정 가능하다.
async def on_model_change(
self, data: dict, model: Todo, is_created: bool, request: Request
) -> None:
for key, value in data.items():
if not isinstance(value, datetime):
continue
if value.utcoffset() is not None:
continue
data[key] = value.astimezone(UTC)
- SQLAdmin은 두 가지 방식으로 열(Column)에 표시할 텍스트 형식을 지정할 수 있는데,
- column_formatters 와 column_type_formatters 이 있다.
- column_formatters 는 열(모델 필드) 단위로,
- column_type_formatters 는 열의 값 자료형에 단위로 형식을 지정한다.
1. 값 자료형 별로 출력 형식 잡아주기
from typing import ClassVar
from datetime import datetime
...
column_type_formatters: ClassVar = {
datetime: lambda v: v.strftime("%Y-%m-%d %H:%M:%S"),
}
2. 열(Column) 별로 출력 형식 잡아주기
- 자료형(type) 대신 모델필드를 키로 사용하는 점이 다르고, 값에 사용할 호출가능한 객체의 인자가 두 개라는 점도 다르다.
3. user profile admin 정의
class UserProfileAdmin(ModelView, model=UserProfile):
name = "사용자 프로필"
name_plural = "사용자 프로필 목록"
icon = "fa-solid fa-user"
category = "사용자 관리"
column_list = [UserProfile.id, UserProfile.full_name, "user.username"]
# form에 표시할 필드
form_columns = [
UserProfile.user, # 직접 user_id를 선택하도록 함
UserProfile.full_name,
UserProfile.bio,
UserProfile.avatar_url,
UserProfile.phone_number,
UserProfile.address,
]
column_searchable_list = (
UserProfile.id,
UserProfile.full_name,
)
column_sortable_list = (
UserProfile.id,
UserProfile.full_name,
# User.email,
)
column_sortable_list = (UserProfile.id, UserProfile.full_name)
column_default_sort = (UserProfile.id, True)
# 디테일 페이지에서 외래 키 선택 시 보여줄 레이블 설정
# 사용자를 검색해서 고르는 방식
form_ajax_refs = {
"user": {
"fields": ["username", "email"], # 검색 가능 필드
"order_by": "username", # 정렬 방식
"page_size": 20, # 한 번에 표시할 사용자 수
}
}
def __str__(self) -> str:
return self.username
4. admin 관리를 위한 커스텀 함수
def init_admin(
app: FastAPI,
db_engine: engine,
base_url: str = "/admin",
) -> Admin:
admin = Admin(
app,
db_engine,
base_url=settings.ADMIN_BASE_URL,
)
admin.add_view(UserAdmin)
admin.add_view(UserProfileAdmin)
admin.add_view(GroupAdmin)
admin.add_view(PostAdmin)
return admin
'Study > fastapi' 카테고리의 다른 글
[udemy] Learning Pydantic: Advanced Data Validation In Python - 학습후기 (0) | 2025.03.25 |
---|---|
fastapi와 sqlalchemy admin인 sqladmin을 이용해 celery beat을 admin으로 관리하기 (0) | 2025.03.22 |
G6의 기본적인 추상클래스 사용 방법/패턴 정리 (0) | 2025.03.10 |
fastapi - G6 간단하게 훓어보기 - 2) API 호출 (0) | 2025.02.21 |
depends() 함수를 정의하는 방법들 (0) | 2025.02.19 |
댓글