Files
waiting-system/routers/system_admin.py
Jun-dev f699a29a85 Add waiting system application files
- Add main application files (main.py, models.py, schemas.py, etc.)
- Add routers for all features (waiting, attendance, members, etc.)
- Add HTML templates for admin and user interfaces
- Add migration scripts and utility files
- Add Docker configuration
- Add documentation files
- Add .gitignore to exclude database and cache files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 00:29:39 +09:00

792 lines
24 KiB
Python

"""
시스템 관리자 라우터
- 프랜차이즈 CRUD
- 프랜차이즈 관리자 생성
- 전체 시스템 통계
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func
from datetime import datetime
from typing import List
from database import get_db
from models import Franchise, Store, User, Member, DailyClosing
from schemas import (
Franchise as FranchiseSchema,
FranchiseCreate,
FranchiseUpdate,
User as UserSchema,
UserCreate,
UserUpdate,
Store as StoreSchema,
StoreCreate,
UserListResponse,
StoreListResponse,
MemberListResponse
)
from auth import require_system_admin, get_password_hash
router = APIRouter()
@router.get("/franchises", response_model=List[FranchiseSchema])
async def get_all_franchises(
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""모든 프랜차이즈 조회"""
franchises = db.query(Franchise).all()
return franchises
@router.get("/franchises/{franchise_id}", response_model=FranchiseSchema)
async def get_franchise(
franchise_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""특정 프랜차이즈 조회"""
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
return franchise
@router.get("/franchises/{franchise_id}/stores", response_model=List[StoreSchema])
async def get_franchise_stores(
franchise_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""특정 프랜차이즈의 매장 목록 조회"""
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
stores = db.query(Store).filter(Store.franchise_id == franchise_id).all()
return stores
@router.get("/franchises/{franchise_id}/users", response_model=List[UserSchema])
async def get_franchise_users(
franchise_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""특정 프랜차이즈의 사용자 목록 조회"""
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
users = db.query(User).filter(User.franchise_id == franchise_id).all()
return users
@router.get("/franchises/{franchise_id}/stats")
async def get_franchise_stats(
franchise_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""특정 프랜차이즈의 통계 조회"""
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
from datetime import date
# 매장 수
total_stores = db.query(func.count(Store.id)).filter(
Store.franchise_id == franchise_id
).scalar()
# 활성 매장 수
active_stores = db.query(func.count(Store.id)).filter(
Store.franchise_id == franchise_id,
Store.is_active == True
).scalar()
# 사용자 수
total_users = db.query(func.count(User.id)).filter(
User.franchise_id == franchise_id
).scalar()
# 오늘 날짜
today = date.today()
# 프랜차이즈 전체 매장의 오늘 통계
stores = db.query(Store).filter(
Store.franchise_id == franchise_id,
Store.is_active == True
).all()
store_ids = [store.id for store in stores]
# 총 회원 수 (모든 매장 합계)
total_members = db.query(func.count(Member.id)).filter(
Member.store_id.in_(store_ids)
).scalar() if store_ids else 0
return {
'franchise_id': franchise_id,
'total_stores': total_stores,
'active_stores': active_stores,
'total_users': total_users,
'total_members': total_members
}
@router.post("/franchises", response_model=FranchiseSchema, status_code=status.HTTP_201_CREATED)
async def create_franchise(
franchise: FranchiseCreate,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""새 프랜차이즈 생성"""
# 코드 중복 체크
existing = db.query(Franchise).filter(Franchise.code == franchise.code).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"프랜차이즈 코드 '{franchise.code}'가 이미 존재합니다"
)
# 프랜차이즈 생성
new_franchise = Franchise(
name=franchise.name,
code=franchise.code,
is_active=True,
created_at=datetime.now(),
updated_at=datetime.now()
)
db.add(new_franchise)
db.commit()
db.refresh(new_franchise)
return new_franchise
@router.put("/franchises/{franchise_id}", response_model=FranchiseSchema)
async def update_franchise(
franchise_id: int,
franchise_update: FranchiseUpdate,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""프랜차이즈 정보 수정"""
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
# 코드 중복 체크 (코드 변경 시)
if franchise_update.code and franchise_update.code != franchise.code:
existing = db.query(Franchise).filter(Franchise.code == franchise_update.code).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"프랜차이즈 코드 '{franchise_update.code}'가 이미 존재합니다"
)
# 수정
update_data = franchise_update.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(franchise, key, value)
franchise.updated_at = datetime.now()
db.commit()
db.refresh(franchise)
return franchise
@router.delete("/franchises/{franchise_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_franchise(
franchise_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""프랜차이즈 삭제 (비활성화)"""
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
# 비활성화
franchise.is_active = False
franchise.updated_at = datetime.now()
db.commit()
return None
@router.post("/franchises/{franchise_id}/activate", response_model=FranchiseSchema)
async def activate_franchise(
franchise_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""프랜차이즈 활성화"""
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
franchise.is_active = True
franchise.updated_at = datetime.now()
db.commit()
db.refresh(franchise)
return franchise
@router.post("/franchises/{franchise_id}/admin", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
async def create_franchise_admin(
franchise_id: int,
user_create: UserCreate,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""프랜차이즈 관리자 생성"""
# 프랜차이즈 존재 확인
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
# 사용자명 중복 확인
existing_user = db.query(User).filter(User.username == user_create.username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"사용자명 '{user_create.username}'가 이미 존재합니다"
)
# 프랜차이즈 관리자만 생성 가능
if user_create.role != "franchise_admin":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이 엔드포인트는 프랜차이즈 관리자 생성 전용입니다"
)
# 사용자 생성
password_hash = get_password_hash(user_create.password)
new_user = User(
username=user_create.username,
password_hash=password_hash,
role="franchise_admin",
franchise_id=franchise_id,
store_id=None,
is_active=True,
created_at=datetime.now(),
updated_at=datetime.now()
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.get("/stats")
async def get_system_stats(
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""전체 시스템 통계"""
# 프랜차이즈 수
total_franchises = db.query(func.count(Franchise.id)).scalar()
active_franchises = db.query(func.count(Franchise.id)).filter(
Franchise.is_active == True
).scalar()
# 매장 수
total_stores = db.query(func.count(Store.id)).scalar()
active_stores = db.query(func.count(Store.id)).filter(
Store.is_active == True
).scalar()
# 사용자 수
total_users = db.query(func.count(User.id)).scalar()
active_users = db.query(func.count(User.id)).filter(
User.is_active == True
).scalar()
# 회원 수
total_members = db.query(func.count(Member.id)).scalar()
# 프랜차이즈별 통계
franchises = db.query(Franchise).all()
franchise_stats = []
for franchise in franchises:
# 프랜차이즈의 매장 수
stores_count = db.query(func.count(Store.id)).filter(
Store.franchise_id == franchise.id
).scalar()
# 프랜차이즈의 활성 매장 수
active_stores_count = db.query(func.count(Store.id)).filter(
Store.franchise_id == franchise.id,
Store.is_active == True
).scalar()
# 프랜차이즈의 사용자 수
users_count = db.query(func.count(User.id)).filter(
User.franchise_id == franchise.id
).scalar()
# 프랜차이즈의 매장 ID 목록
store_ids = [s.id for s in db.query(Store.id).filter(
Store.franchise_id == franchise.id
).all()]
# 프랜차이즈의 회원 수
members_count = db.query(func.count(Member.id)).filter(
Member.store_id.in_(store_ids)
).scalar() if store_ids else 0
franchise_stats.append({
"franchise_id": franchise.id,
"franchise_name": franchise.name,
"franchise_code": franchise.code,
"is_active": franchise.is_active,
"stores_count": stores_count,
"active_stores_count": active_stores_count,
"users_count": users_count,
"members_count": members_count
})
return {
"total_franchises": total_franchises,
"active_franchises": active_franchises,
"total_stores": total_stores,
"active_stores": active_stores,
"total_users": total_users,
"active_users": active_users,
"total_members": total_members,
"franchises": franchise_stats
}
# ========== 매장 관리 (Superadmin) ==========
@router.post("/franchises/{franchise_id}/stores", response_model=StoreSchema, status_code=status.HTTP_201_CREATED)
async def create_store_for_franchise(
franchise_id: int,
store_create: StoreCreate,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""특정 프랜차이즈의 매장 생성 (Superadmin 전용)"""
# 프랜차이즈 존재 확인
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
# 매장 코드 자동 생성
prefix = franchise.code[0] if franchise.code else "S"
# 해당 프랜차이즈의 기존 매장 중 같은 prefix를 가진 매장 코드에서 가장 큰 번호 찾기
stores = db.query(Store).filter(Store.franchise_id == franchise_id).all()
max_number = 0
for store in stores:
if store.code.startswith(prefix) and len(store.code) > 1:
try:
number = int(store.code[1:])
if number > max_number:
max_number = number
except ValueError:
continue
# 새로운 매장 코드 생성 (예: S001, S002, S003...)
new_code = f"{prefix}{str(max_number + 1).zfill(3)}"
# 매장 생성
new_store = Store(
franchise_id=franchise_id,
name=store_create.name,
code=new_code,
is_active=True
)
db.add(new_store)
db.commit()
db.refresh(new_store)
return new_store
@router.post("/stores/{store_id}/activate", response_model=StoreSchema)
async def activate_store(
store_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""매장 활성화 (Superadmin 전용)"""
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없습니다"
)
store.is_active = True
db.commit()
db.refresh(store)
return store
@router.post("/stores/{store_id}/deactivate", response_model=StoreSchema)
async def deactivate_store(
store_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""매장 비활성화 (Superadmin 전용)"""
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없습니다"
)
store.is_active = False
db.commit()
db.refresh(store)
return store
# ========== 사용자 관리 (Superadmin) ==========
@router.post("/franchises/{franchise_id}/users", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
async def create_user_for_franchise(
franchise_id: int,
user_create: UserCreate,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""특정 프랜차이즈의 사용자 생성 (Superadmin 전용)"""
# 프랜차이즈 존재 확인
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
# 사용자명 중복 확인
existing_user = db.query(User).filter(User.username == user_create.username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이미 존재하는 사용자명입니다"
)
# 역할 검증
if user_create.role not in ['franchise_admin', 'store_admin', 'franchise_manager']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="올바르지 않은 역할입니다."
)
# 매장 관리자인 경우 매장 ID 필수
if user_create.role == 'store_admin' and not user_create.store_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="매장 관리자는 매장 ID가 필요합니다"
)
# 매장 ID가 있는 경우 해당 매장이 프랜차이즈에 속하는지 확인
if user_create.store_id:
store = db.query(Store).filter(
Store.id == user_create.store_id,
Store.franchise_id == franchise_id
).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없거나 해당 프랜차이즈에 속하지 않습니다"
)
# 사용자 생성
password_hash = get_password_hash(user_create.password)
new_user = User(
username=user_create.username,
password_hash=password_hash,
role=user_create.role,
franchise_id=franchise_id,
store_id=user_create.store_id,
is_active=True,
created_at=datetime.now(),
updated_at=datetime.now()
)
# 중간 관리자의 매장 권한 설정
if user_create.role == 'franchise_manager' and user_create.managed_store_ids:
stores = db.query(Store).filter(
Store.id.in_(user_create.managed_store_ids),
Store.franchise_id == franchise_id
).all()
if len(stores) != len(user_create.managed_store_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="일부 매장을 찾을 수 없거나 해당 프랜차이즈에 속하지 않습니다"
)
new_user.managed_stores = stores
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.put("/users/{user_id}", response_model=UserSchema)
async def update_user(
user_id: int,
user_update: UserUpdate,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""사용자 정보 수정 (Superadmin 전용)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없습니다"
)
# 사용자명 변경 시 중복 확인
if user_update.username and user_update.username != user.username:
existing_user = db.query(User).filter(User.username == user_update.username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이미 존재하는 사용자명입니다"
)
# 역할 변경 시 검증
if user_update.role and user_update.role not in ['franchise_admin', 'store_admin', 'franchise_manager']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="올바르지 않은 역할입니다."
)
# 매장 ID 변경 시 검증
if user_update.store_id:
store = db.query(Store).filter(Store.id == user_update.store_id).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없습니다"
)
# 수정
update_data = user_update.dict(exclude_unset=True, exclude={'password', 'managed_store_ids'})
for key, value in update_data.items():
setattr(user, key, value)
# 중간 관리자의 매장 권한 수정
if user_update.role == 'franchise_manager' and user_update.managed_store_ids is not None:
stores = db.query(Store).filter(
Store.id.in_(user_update.managed_store_ids),
Store.franchise_id == user.franchise_id
).all()
user.managed_stores = stores
elif user_update.role != 'franchise_manager':
user.managed_stores = []
# 비밀번호 변경이 있는 경우
if user_update.password:
user.password_hash = get_password_hash(user_update.password)
user.updated_at = datetime.now()
db.commit()
db.refresh(user)
return user
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def deactivate_user(
user_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""사용자 비활성화 (Superadmin 전용)"""
# 자기 자신은 비활성화할 수 없음
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="자기 자신을 비활성화할 수 없습니다"
)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없습니다"
)
user.is_active = False
user.updated_at = datetime.now()
db.commit()
return None
@router.post("/users/{user_id}/activate", response_model=UserSchema)
async def activate_user(
user_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""사용자 활성화 (Superadmin 전용)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없습니다"
)
user.is_active = True
user.updated_at = datetime.now()
db.commit()
db.refresh(user)
return user
@router.get("/users", response_model=List[UserListResponse])
async def get_all_users(
skip: int = 0,
limit: int = 1000,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""전체 사용자 조회 (System Admin)"""
users = db.query(User).options(
joinedload(User.franchise),
joinedload(User.store)
).offset(skip).limit(limit).all()
response = []
for user in users:
user_dict = UserListResponse.from_orm(user)
if user.franchise:
user_dict.franchise_name = user.franchise.name
if user.store:
user_dict.store_name = user.store.name
response.append(user_dict)
return response
@router.get("/stores", response_model=List[StoreListResponse])
async def get_all_stores(
skip: int = 0,
limit: int = 1000,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""전체 매장 조회 (System Admin)"""
stores = db.query(Store).options(
joinedload(Store.franchise)
).offset(skip).limit(limit).all()
response = []
for store in stores:
store_dict = StoreListResponse.from_orm(store)
if store.franchise:
store_dict.franchise_name = store.franchise.name
response.append(store_dict)
return response
@router.get("/members", response_model=List[MemberListResponse])
async def search_members(
q: str = None,
skip: int = 0,
limit: int = 100,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""회원 검색 및 조회 (System Admin)"""
query = db.query(Member).options(
joinedload(Member.store).joinedload(Store.franchise)
)
if q:
query = query.filter(
(Member.name.ilike(f"%{q}%")) |
(Member.phone.ilike(f"%{q}%"))
)
members = query.order_by(Member.created_at.desc()).offset(skip).limit(limit).all()
response = []
for member in members:
member_dict = MemberListResponse.from_orm(member)
if member.store:
member_dict.store_name = member.store.name
if member.store.franchise:
member_dict.franchise_name = member.store.franchise.name
response.append(member_dict)
return response