백오피스 관리자 권한 분리 및 메인화면 대시보드 추가
parent
37bf9f54ee
commit
e99fddd66a
|
|
@ -1,14 +1,32 @@
|
|||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from sqladmin import Admin
|
||||
from sqladmin.authentication import login_required
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from app.backoffice.admin.admin_view import AdminAdmin
|
||||
from app.backoffice.admin.auth import AdminAuthBackend
|
||||
from app.backoffice.credit_view import CreditChargeRequestAdmin, CreditTransactionAdmin, CreditTransactionAdmin
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from app.user.api.user_admin import RefreshTokenAdmin, SocialAccountAdmin, UserAdmin
|
||||
from app.backoffice.credit_view import CreditChargeRequestAdmin, CreditTransactionAdmin
|
||||
from app.backoffice.dashboard import get_dashboard_context
|
||||
from app.user.api.user_admin import SocialAccountAdmin, UserAdmin
|
||||
from config import prj_settings
|
||||
|
||||
# https://github.com/aminalaee/sqladmin
|
||||
TEMPLATES_DIR = Path(__file__).parent / "backoffice" / "frontend" / "templates"
|
||||
|
||||
|
||||
class DashboardAdmin(Admin):
|
||||
@login_required
|
||||
async def index(self, request: Request) -> Response:
|
||||
ctx = await get_dashboard_context()
|
||||
admin_role = request.session.get("admin_role", "viewer")
|
||||
return await self.templates.TemplateResponse(
|
||||
request,
|
||||
"sqladmin/index.html",
|
||||
{"title": "대시보드", "subtitle": "", "admin_role": admin_role, **ctx},
|
||||
)
|
||||
|
||||
|
||||
def init_admin(
|
||||
|
|
@ -18,19 +36,20 @@ def init_admin(
|
|||
) -> Admin:
|
||||
auth_backend = AdminAuthBackend(secret_key=prj_settings.ADMIN_SESSION_SECRET)
|
||||
|
||||
admin = Admin(
|
||||
admin = DashboardAdmin(
|
||||
app,
|
||||
db_engine,
|
||||
base_url=base_url,
|
||||
authentication_backend=auth_backend,
|
||||
title="ADO2 관리자",
|
||||
templates_dir=str(TEMPLATES_DIR),
|
||||
)
|
||||
|
||||
# 사용자 관리
|
||||
admin.add_view(UserAdmin)
|
||||
admin.add_view(SocialAccountAdmin)
|
||||
|
||||
# 크레딧 관리
|
||||
# 크레딧 관리 (superadmin: 전체, viewer: 읽기 전용)
|
||||
admin.add_view(CreditChargeRequestAdmin)
|
||||
admin.add_view(CreditTransactionAdmin)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,22 @@
|
|||
from sqladmin import ModelView
|
||||
from wtforms import PasswordField, SelectField
|
||||
|
||||
from app.backoffice.admin.models import Admin
|
||||
from app.backoffice.mixins import SuperAdminOnly
|
||||
|
||||
|
||||
class AdminAdmin(ModelView, model=Admin):
|
||||
class AdminAdmin(SuperAdminOnly, ModelView, model=Admin):
|
||||
name = "관리자 계정"
|
||||
name_plural = "관리자 계정 목록"
|
||||
icon = "fa-solid fa-user-shield"
|
||||
category = "백오피스 설정"
|
||||
page_size = 20
|
||||
page_size = 30
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"username",
|
||||
"name",
|
||||
"role",
|
||||
"is_active",
|
||||
"last_login_at",
|
||||
"created_at",
|
||||
|
|
@ -23,13 +26,27 @@ class AdminAdmin(ModelView, model=Admin):
|
|||
"id",
|
||||
"username",
|
||||
"name",
|
||||
"role",
|
||||
"is_active",
|
||||
"last_login_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
form_columns = ["username", "name", "is_active"]
|
||||
form_columns = ["username", "password", "name", "role", "is_active"]
|
||||
|
||||
form_overrides = {
|
||||
"password": PasswordField,
|
||||
"role": SelectField,
|
||||
}
|
||||
|
||||
form_args = {
|
||||
"role": {
|
||||
"label": "권한",
|
||||
"choices": [("superadmin", "전체 관리자"), ("viewer", "조회 전용")],
|
||||
"default": "viewer",
|
||||
}
|
||||
}
|
||||
|
||||
column_searchable_list = [Admin.username, Admin.name]
|
||||
|
||||
|
|
@ -47,6 +64,7 @@ class AdminAdmin(ModelView, model=Admin):
|
|||
"id": "ID",
|
||||
"username": "아이디",
|
||||
"name": "이름",
|
||||
"role": "권한",
|
||||
"is_active": "활성화",
|
||||
"last_login_at": "마지막 로그인",
|
||||
"created_at": "생성일시",
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ class AdminAuthBackend(AuthenticationBackend):
|
|||
return False
|
||||
|
||||
request.session["admin_id"] = admin.id
|
||||
logger.info(f"[ADMIN-AUTH] login success admin_id={admin.id} username={username}")
|
||||
request.session["admin_role"] = admin.role
|
||||
logger.info(f"[ADMIN-AUTH] login success admin_id={admin.id} username={username} role={admin.role}")
|
||||
|
||||
# 마지막 로그인 시간 갱신
|
||||
async with AsyncSessionLocal() as session:
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ class Admin(Base):
|
|||
comment="표시 이름",
|
||||
)
|
||||
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="viewer",
|
||||
server_default="viewer",
|
||||
comment="권한 (superadmin: 전체, viewer: 조회만)",
|
||||
)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import logging
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import outerjoin
|
||||
from sqladmin import ModelView, action
|
||||
from app.backoffice.mixins import SuperAdminEditable
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
|
|
@ -14,12 +15,12 @@ from app.database.session import AsyncSessionLocal
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreditChargeRequestAdmin(ModelView, model=CreditChargeRequest):
|
||||
class CreditChargeRequestAdmin(SuperAdminEditable, ModelView, model=CreditChargeRequest):
|
||||
name = "충전 요청"
|
||||
name_plural = "충전 요청 목록"
|
||||
icon = "fa-solid fa-coins"
|
||||
category = "크레딧 관리"
|
||||
page_size = 20
|
||||
page_size = 30
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
|
|
@ -134,12 +135,12 @@ class CreditChargeRequestAdmin(ModelView, model=CreditChargeRequest):
|
|||
return RedirectResponse(request.url_for("admin:list", identity=self.identity), status_code=302)
|
||||
|
||||
|
||||
class CreditTransactionAdmin(ModelView, model=CreditTransaction):
|
||||
class CreditTransactionAdmin(SuperAdminEditable, ModelView, model=CreditTransaction):
|
||||
name = "크레딧 변경"
|
||||
name_plural = "크레딧 변경 목록"
|
||||
icon = "fa-solid fa-clock-rotate-left"
|
||||
category = "크레딧 관리"
|
||||
page_size = 50
|
||||
page_size = 30
|
||||
|
||||
can_create = False
|
||||
can_edit = False
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.credit.models import ChargeRequestStatus, CreditChargeRequest, CreditTransaction, CreditTransactionType
|
||||
from app.database.session import AsyncSessionLocal
|
||||
from app.user.models import User
|
||||
from config import TIMEZONE
|
||||
|
||||
|
||||
async def get_dashboard_context() -> dict:
|
||||
async with AsyncSessionLocal() as session:
|
||||
today_start = datetime.now(TIMEZONE).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
total_users = (await session.execute(
|
||||
select(func.count()).select_from(User).where(User.is_deleted == False)
|
||||
)).scalar()
|
||||
|
||||
pending_charge_requests_count = (await session.execute(
|
||||
select(func.count()).select_from(CreditChargeRequest)
|
||||
.where(CreditChargeRequest.status == ChargeRequestStatus.PENDING)
|
||||
)).scalar()
|
||||
|
||||
today_consume = (await session.execute(
|
||||
select(func.count()).select_from(CreditTransaction)
|
||||
.where(
|
||||
CreditTransaction.type == CreditTransactionType.CONSUME,
|
||||
CreditTransaction.created_at >= today_start,
|
||||
)
|
||||
)).scalar()
|
||||
|
||||
today_charge = (await session.execute(
|
||||
select(func.count()).select_from(CreditTransaction)
|
||||
.where(
|
||||
CreditTransaction.type == CreditTransactionType.CHARGE,
|
||||
CreditTransaction.created_at >= today_start,
|
||||
)
|
||||
)).scalar()
|
||||
|
||||
pending_requests = (await session.execute(
|
||||
select(CreditChargeRequest)
|
||||
.where(CreditChargeRequest.status == ChargeRequestStatus.PENDING)
|
||||
.order_by(CreditChargeRequest.created_at.desc())
|
||||
.limit(10)
|
||||
)).scalars().all()
|
||||
|
||||
recent_transactions = (await session.execute(
|
||||
select(CreditTransaction)
|
||||
.order_by(CreditTransaction.created_at.desc())
|
||||
.limit(10)
|
||||
)).scalars().all()
|
||||
|
||||
recent_users = (await session.execute(
|
||||
select(User)
|
||||
.where(User.is_deleted == False)
|
||||
.order_by(User.created_at.desc())
|
||||
.limit(10)
|
||||
)).scalars().all()
|
||||
|
||||
return {
|
||||
"stats": {
|
||||
"total_users": total_users,
|
||||
"pending_charge_requests": pending_charge_requests_count,
|
||||
"today_consume": today_consume,
|
||||
"today_charge": today_charge,
|
||||
},
|
||||
"pending_requests": pending_requests,
|
||||
"recent_transactions": recent_transactions,
|
||||
"recent_users": recent_users,
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
{% extends "sqladmin/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<!-- 요약 카드 -->
|
||||
<div class="col-12">
|
||||
<div class="row row-deck row-cards mb-4">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">전체 사용자</div>
|
||||
<div class="h1 mb-3">{{ stats.total_users }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">대기 중 충전 요청</div>
|
||||
<div class="h1 mb-3 text-warning">{{ stats.pending_charge_requests }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">오늘 크레딧 소모</div>
|
||||
<div class="h1 mb-3 text-danger">{{ stats.today_consume }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="subheader">오늘 충전 승인</div>
|
||||
<div class="h1 mb-3 text-success">{{ stats.today_charge }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 대기 중 충전 요청 -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">대기 중 충전 요청</h3>
|
||||
<div class="card-options">
|
||||
<a href="{{ request.url_for('admin:list', identity='credit-charge-request') }}" class="btn btn-sm btn-primary">전체 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자 UUID</th>
|
||||
<th>요청 크레딧</th>
|
||||
<th>요청일시</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for req in pending_requests %}
|
||||
<tr>
|
||||
<td class="text-truncate" style="max-width:150px;">{{ req.user_uuid }}</td>
|
||||
<td>{{ req.requested_amount }}</td>
|
||||
<td>{{ req.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="text-center text-muted">대기 중인 요청 없음</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 크레딧 변화 -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">최근 크레딧 변화</h3>
|
||||
<div class="card-options">
|
||||
<a href="{{ request.url_for('admin:list', identity='credit-transaction') }}" class="btn btn-sm btn-primary">전체 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>사용자 UUID</th>
|
||||
<th>유형</th>
|
||||
<th>변경</th>
|
||||
<th>잔액</th>
|
||||
<th>일시</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tx in recent_transactions %}
|
||||
<tr>
|
||||
<td class="text-truncate" style="max-width:120px;">{{ tx.user_uuid }}</td>
|
||||
<td>{{ tx.type }}</td>
|
||||
<td class="{{ 'text-success' if tx.amount > 0 else 'text-danger' }}">{{ '%+d' % tx.amount }}</td>
|
||||
<td>{{ tx.balance_after }}</td>
|
||||
<td>{{ tx.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-muted">이력 없음</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 가입 사용자 -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">최근 가입 사용자</h3>
|
||||
<div class="card-options">
|
||||
<a href="{{ request.url_for('admin:list', identity='user') }}" class="btn btn-sm btn-primary">전체 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>닉네임</th>
|
||||
<th>이메일</th>
|
||||
<th>크레딧</th>
|
||||
<th>권한</th>
|
||||
<th>가입일시</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in recent_users %}
|
||||
<tr>
|
||||
<td>{{ user.nickname or '-' }}</td>
|
||||
<td>{{ user.email or '-' }}</td>
|
||||
<td>{{ user.credits }}</td>
|
||||
<td>{{ user.role }}</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
from starlette.requests import Request
|
||||
|
||||
|
||||
class SuperAdminOnly:
|
||||
"""superadmin만 접근 가능 (편집/삭제/액션 모두 허용)"""
|
||||
|
||||
def is_accessible(self, request: Request) -> bool:
|
||||
return request.session.get("admin_role") == "superadmin"
|
||||
|
||||
|
||||
class ViewerReadOnly:
|
||||
"""viewer만 접근 가능한 읽기 전용 뷰"""
|
||||
|
||||
can_create = False
|
||||
can_edit = False
|
||||
can_delete = False
|
||||
|
||||
def is_accessible(self, request: Request) -> bool:
|
||||
return request.session.get("admin_role") == "viewer"
|
||||
|
||||
|
||||
class ViewerAccessible:
|
||||
"""superadmin + viewer 접근 가능, 읽기 전용"""
|
||||
|
||||
can_create = False
|
||||
can_edit = False
|
||||
can_delete = False
|
||||
|
||||
def is_accessible(self, request: Request) -> bool:
|
||||
return request.session.get("admin_role") in ("superadmin", "viewer")
|
||||
|
||||
|
||||
class SuperAdminEditable:
|
||||
"""superadmin + viewer 접근 가능, superadmin만 편집"""
|
||||
|
||||
can_create = False
|
||||
can_edit = False
|
||||
can_delete = False
|
||||
|
||||
def is_accessible(self, request: Request) -> bool:
|
||||
return request.session.get("admin_role") in ("superadmin", "viewer")
|
||||
|
|
@ -4,27 +4,27 @@ from sqladmin import ModelView, action
|
|||
from starlette.requests import Request
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from app.backoffice.mixins import SuperAdminEditable, ViewerAccessible
|
||||
from app.backoffice.user_view_actions import (
|
||||
handle_block_users,
|
||||
handle_deduct_credits,
|
||||
handle_grant_credits,
|
||||
handle_set_role,
|
||||
)
|
||||
from app.user.models import RefreshToken, SocialAccount, User
|
||||
from app.user.models import SocialAccount, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserAdmin(ModelView, model=User):
|
||||
class UserAdmin(SuperAdminEditable, ModelView, model=User):
|
||||
name = "사용자"
|
||||
name_plural = "사용자 목록"
|
||||
icon = "fa-solid fa-user"
|
||||
category = "사용자 관리"
|
||||
page_size = 20
|
||||
page_size = 30
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"kakao_id",
|
||||
"user_uuid",
|
||||
"email",
|
||||
"nickname",
|
||||
"credits",
|
||||
|
|
@ -36,7 +36,7 @@ class UserAdmin(ModelView, model=User):
|
|||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"kakao_id",
|
||||
"user_uuid",
|
||||
"email",
|
||||
"nickname",
|
||||
"profile_image_url",
|
||||
|
|
@ -73,7 +73,7 @@ class UserAdmin(ModelView, model=User):
|
|||
]
|
||||
|
||||
column_searchable_list = [
|
||||
User.kakao_id,
|
||||
User.user_uuid,
|
||||
User.email,
|
||||
User.nickname,
|
||||
User.phone,
|
||||
|
|
@ -84,7 +84,7 @@ class UserAdmin(ModelView, model=User):
|
|||
|
||||
column_sortable_list = [
|
||||
User.id,
|
||||
User.kakao_id,
|
||||
User.user_uuid,
|
||||
User.email,
|
||||
User.nickname,
|
||||
User.credits,
|
||||
|
|
@ -96,13 +96,13 @@ class UserAdmin(ModelView, model=User):
|
|||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"kakao_id": "카카오 ID",
|
||||
"user_uuid": "UUID",
|
||||
"email": "이메일",
|
||||
"nickname": "닉네임",
|
||||
"profile_image_url": "프로필 이미지",
|
||||
"thumbnail_image_url": "썸네일 이미지",
|
||||
"phone": "전화번호",
|
||||
"name": "실명",
|
||||
"name": "이름",
|
||||
"birth_date": "생년월일",
|
||||
"gender": "성별",
|
||||
"credits": "크레딧",
|
||||
|
|
@ -119,7 +119,7 @@ class UserAdmin(ModelView, model=User):
|
|||
@action(
|
||||
name="block_user",
|
||||
label="계정 차단",
|
||||
confirmation_message="선택한 사용자를 차단하시겠습니까? 로그인이 불가해집니다.",
|
||||
confirmation_message="선택한 사용자를 차단하시겠습니까?",
|
||||
add_in_list=True,
|
||||
)
|
||||
async def block_user_action(self, request: Request) -> RedirectResponse:
|
||||
|
|
@ -134,27 +134,10 @@ class UserAdmin(ModelView, model=User):
|
|||
async def unblock_user_action(self, request: Request) -> RedirectResponse:
|
||||
return await handle_block_users(request, self.identity, block=False)
|
||||
|
||||
@action(
|
||||
name="set_role_admin",
|
||||
label="권한: admin으로 변경",
|
||||
confirmation_message="선택한 사용자를 admin으로 변경하시겠습니까?",
|
||||
add_in_list=True,
|
||||
)
|
||||
async def set_role_admin_action(self, request: Request) -> RedirectResponse:
|
||||
return await handle_set_role(request, self.identity, role="admin")
|
||||
|
||||
@action(
|
||||
name="set_role_user",
|
||||
label="권한: user로 변경",
|
||||
confirmation_message="선택한 사용자를 일반 user로 변경하시겠습니까?",
|
||||
add_in_list=True,
|
||||
)
|
||||
async def set_role_user_action(self, request: Request) -> RedirectResponse:
|
||||
return await handle_set_role(request, self.identity, role="user")
|
||||
|
||||
@action(
|
||||
name="grant_credits_1",
|
||||
label="크레딧 +1 충전",
|
||||
label="크레딧 +1",
|
||||
confirmation_message="선택한 사용자에게 크레딧 1개를 충전하시겠습니까?",
|
||||
add_in_list=True,
|
||||
)
|
||||
|
|
@ -164,7 +147,7 @@ class UserAdmin(ModelView, model=User):
|
|||
|
||||
@action(
|
||||
name="grant_credits_5",
|
||||
label="크레딧 +5 충전",
|
||||
label="크레딧 +5",
|
||||
confirmation_message="선택한 사용자에게 크레딧 5개를 충전하시겠습니까?",
|
||||
add_in_list=True,
|
||||
)
|
||||
|
|
@ -174,7 +157,7 @@ class UserAdmin(ModelView, model=User):
|
|||
|
||||
@action(
|
||||
name="grant_credits_10",
|
||||
label="크레딧 +10 충전",
|
||||
label="크레딧 +10",
|
||||
confirmation_message="선택한 사용자에게 크레딧 10개를 충전하시겠습니까?",
|
||||
add_in_list=True,
|
||||
)
|
||||
|
|
@ -184,7 +167,7 @@ class UserAdmin(ModelView, model=User):
|
|||
|
||||
@action(
|
||||
name="deduct_credits_1",
|
||||
label="크레딧 -1 차감",
|
||||
label="크레딧 -1",
|
||||
confirmation_message="선택한 사용자의 크레딧 1개를 차감하시겠습니까?",
|
||||
add_in_list=True,
|
||||
)
|
||||
|
|
@ -193,70 +176,12 @@ class UserAdmin(ModelView, model=User):
|
|||
return await handle_deduct_credits(request, self.identity, amount=1, admin_id=admin_id)
|
||||
|
||||
|
||||
class RefreshTokenAdmin(ModelView, model=RefreshToken):
|
||||
name = "리프레시 토큰"
|
||||
name_plural = "리프레시 토큰 목록"
|
||||
icon = "fa-solid fa-key"
|
||||
category = "사용자 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"is_revoked",
|
||||
"expires_at",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"token_hash",
|
||||
"expires_at",
|
||||
"is_revoked",
|
||||
"created_at",
|
||||
"revoked_at",
|
||||
"user_agent",
|
||||
"ip_address",
|
||||
]
|
||||
|
||||
form_excluded_columns = ["created_at", "user"]
|
||||
|
||||
column_searchable_list = [
|
||||
RefreshToken.user_id,
|
||||
RefreshToken.token_hash,
|
||||
RefreshToken.ip_address,
|
||||
]
|
||||
|
||||
column_default_sort = (RefreshToken.created_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
RefreshToken.id,
|
||||
RefreshToken.user_id,
|
||||
RefreshToken.is_revoked,
|
||||
RefreshToken.expires_at,
|
||||
RefreshToken.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"user_id": "사용자 ID",
|
||||
"token_hash": "토큰 해시",
|
||||
"expires_at": "만료일시",
|
||||
"is_revoked": "폐기됨",
|
||||
"created_at": "생성일시",
|
||||
"revoked_at": "폐기일시",
|
||||
"user_agent": "User Agent",
|
||||
"ip_address": "IP 주소",
|
||||
}
|
||||
|
||||
|
||||
class SocialAccountAdmin(ModelView, model=SocialAccount):
|
||||
class SocialAccountAdmin(ViewerAccessible, ModelView, model=SocialAccount):
|
||||
name = "소셜 계정"
|
||||
name_plural = "소셜 계정 목록"
|
||||
icon = "fa-solid fa-share-nodes"
|
||||
category = "사용자 관리"
|
||||
page_size = 20
|
||||
page_size = 30
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS `admin` (
|
|||
username VARCHAR(50) NOT NULL COMMENT '로그인 ID',
|
||||
password VARCHAR(255) NOT NULL COMMENT '비밀번호',
|
||||
name VARCHAR(50) NULL COMMENT '표시 이름',
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'superadmin' COMMENT '권한 (superadmin: 전체, viewer: 조회만)',
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '활성화 상태 (0: 비활성화)',
|
||||
last_login_at DATETIME NULL COMMENT '마지막 로그인 일시',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시',
|
||||
|
|
|
|||
Loading…
Reference in New Issue