diff --git a/app/admin_manager.py b/app/admin_manager.py index 4bf0bd0..670eb13 100644 --- a/app/admin_manager.py +++ b/app/admin_manager.py @@ -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) diff --git a/app/backoffice/admin/admin_view.py b/app/backoffice/admin/admin_view.py index d50a321..7ab56b0 100644 --- a/app/backoffice/admin/admin_view.py +++ b/app/backoffice/admin/admin_view.py @@ -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": "생성일시", diff --git a/app/backoffice/admin/auth.py b/app/backoffice/admin/auth.py index d7e44be..7c55642 100644 --- a/app/backoffice/admin/auth.py +++ b/app/backoffice/admin/auth.py @@ -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: diff --git a/app/backoffice/admin/models.py b/app/backoffice/admin/models.py index 77cf02e..fd6fd83 100644 --- a/app/backoffice/admin/models.py +++ b/app/backoffice/admin/models.py @@ -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, diff --git a/app/backoffice/credit_view.py b/app/backoffice/credit_view.py index 0a737fa..026426c 100644 --- a/app/backoffice/credit_view.py +++ b/app/backoffice/credit_view.py @@ -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 diff --git a/app/backoffice/dashboard.py b/app/backoffice/dashboard.py new file mode 100644 index 0000000..cd39693 --- /dev/null +++ b/app/backoffice/dashboard.py @@ -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, + } diff --git a/app/backoffice/frontend/templates/sqladmin/index.html b/app/backoffice/frontend/templates/sqladmin/index.html new file mode 100644 index 0000000..d6fe8e6 --- /dev/null +++ b/app/backoffice/frontend/templates/sqladmin/index.html @@ -0,0 +1,149 @@ +{% extends "sqladmin/layout.html" %} + +{% block content %} + +
+
+
+
+
+
전체 사용자
+
{{ stats.total_users }}
+
+
+
+
+
+
+
대기 중 충전 요청
+
{{ stats.pending_charge_requests }}
+
+
+
+
+
+
+
오늘 크레딧 소모
+
{{ stats.today_consume }}
+
+
+
+
+
+
+
오늘 충전 승인
+
{{ stats.today_charge }}
+
+
+
+
+
+ + +
+
+
+

대기 중 충전 요청

+ +
+
+ + + + + + + + + + {% for req in pending_requests %} + + + + + + {% else %} + + {% endfor %} + +
사용자 UUID요청 크레딧요청일시
{{ req.user_uuid }}{{ req.requested_amount }}{{ req.created_at.strftime('%Y-%m-%d %H:%M') }}
대기 중인 요청 없음
+
+
+
+ + +
+
+
+

최근 크레딧 변화

+ +
+
+ + + + + + + + + + + + {% for tx in recent_transactions %} + + + + + + + + {% else %} + + {% endfor %} + +
사용자 UUID유형변경잔액일시
{{ tx.user_uuid }}{{ tx.type }}{{ '%+d' % tx.amount }}{{ tx.balance_after }}{{ tx.created_at.strftime('%Y-%m-%d %H:%M') }}
이력 없음
+
+
+
+ + +
+
+
+

최근 가입 사용자

+ +
+
+ + + + + + + + + + + + {% for user in recent_users %} + + + + + + + + {% endfor %} + +
닉네임이메일크레딧권한가입일시
{{ user.nickname or '-' }}{{ user.email or '-' }}{{ user.credits }}{{ user.role }}{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}
+
+
+
+{% endblock %} diff --git a/app/backoffice/mixins.py b/app/backoffice/mixins.py new file mode 100644 index 0000000..f612bf5 --- /dev/null +++ b/app/backoffice/mixins.py @@ -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") diff --git a/app/user/api/user_admin.py b/app/user/api/user_admin.py index 6e619e2..8cc700e 100644 --- a/app/user/api/user_admin.py +++ b/app/user/api/user_admin.py @@ -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", diff --git a/docs/database-schema/migration_2026_04_29_add_admin_table.sql b/docs/database-schema/migration_2026_04_29_add_admin_table.sql index f895b64..596ccf1 100644 --- a/docs/database-schema/migration_2026_04_29_add_admin_table.sql +++ b/docs/database-schema/migration_2026_04_29_add_admin_table.sql @@ -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 '생성 일시',