백오피스 관리자 권한 분리 및 메인화면 대시보드 추가

feature-credit
김성경 2026-05-06 13:13:27 +09:00
parent 37bf9f54ee
commit e99fddd66a
10 changed files with 342 additions and 106 deletions

View File

@ -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)

View File

@ -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": "생성일시",

View File

@ -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:

View File

@ -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,

View File

@ -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

View File

@ -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,
}

View File

@ -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 %}

41
app/backoffice/mixins.py Normal file
View File

@ -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")

View File

@ -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",

View File

@ -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 '생성 일시',