feat: 구매신청 기능 완성 및 SUPPORT/SPECIAL 카테고리 개선
- 모든 카테고리 구매신청 기능 완성 (PIPE, FITTING, VALVE, FLANGE, GASKET, BOLT, SUPPORT, SPECIAL, UNKNOWN) - 구매신청 완료 항목: 회색 배경, 체크박스 비활성화, '구매신청완료' 배지 표시 - 전체 선택/구매신청 시 이미 구매신청된 항목 자동 제외 - 구매신청 quantity 타입 에러 수정 (문자열 -> 정수 변환) SUPPORT 카테고리 (구 U-BOLT): - U-BOLT -> SUPPORT로 카테고리명 변경 - 클램프, 유볼트, 우레탄블럭슈 분류 개선 - 테이블 헤더: 선택-종류-타입-크기-디스크립션-추가요구-사용자요구-수량 - 크기 정보 main_nom 필드에서 가져오기 (배관 인치) - 엑셀 내보내기 형식 조정 SPECIAL 카테고리: - SPECIAL 키워드 자재 자동 분류 (SPECIFICATION 제외) - 파일 업로드 시 SPECIAL 카테고리 처리 로직 추가 - 도면번호 필드 추가 (drawing_name, line_no) - 타입 필드: 크기/스케줄/재질 제외한 핵심 정보 표시 - 엑셀 DWG_NAME, LINE_NUM 컬럼 파싱 및 저장 FITTING 카테고리: - 테이블 컬럼 너비 조정 (선택 2%, 종류 8.5%, 수량 12%) 구매신청 관리: - 엑셀 재다운로드 형식 개선 (BOM 페이지와 동일한 형식) - 그룹화된 자재 정보 포함하여 저장 및 다운로드
This commit is contained in:
@@ -323,6 +323,75 @@ async def verify_token(
|
||||
|
||||
|
||||
# 관리자 전용 엔드포인트들
|
||||
@router.get("/users/suspended")
|
||||
async def get_suspended_users(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
정지된 사용자 목록 조회 (관리자 전용)
|
||||
|
||||
Args:
|
||||
credentials: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Dict: 정지된 사용자 목록
|
||||
"""
|
||||
try:
|
||||
# 토큰 검증 및 권한 확인
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
if payload['role'] not in ['admin', 'system']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자 이상의 권한이 필요합니다"
|
||||
)
|
||||
|
||||
# 정지된 사용자 조회
|
||||
from sqlalchemy import text
|
||||
query = text("""
|
||||
SELECT
|
||||
user_id, username, name, email, role, department, position,
|
||||
phone, status, created_at, updated_at
|
||||
FROM users
|
||||
WHERE status = 'suspended'
|
||||
ORDER BY updated_at DESC
|
||||
""")
|
||||
|
||||
results = db.execute(query).fetchall()
|
||||
|
||||
suspended_users = []
|
||||
for row in results:
|
||||
suspended_users.append({
|
||||
"user_id": row.user_id,
|
||||
"username": row.username,
|
||||
"name": row.name,
|
||||
"email": row.email,
|
||||
"role": row.role,
|
||||
"department": row.department,
|
||||
"position": row.position,
|
||||
"phone": row.phone,
|
||||
"status": row.status,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"users": suspended_users,
|
||||
"count": len(suspended_users)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get suspended users: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="정지된 사용자 목록 조회 중 오류가 발생했습니다"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def get_all_users(
|
||||
skip: int = 0,
|
||||
@@ -371,6 +440,155 @@ async def get_all_users(
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}/suspend")
|
||||
async def suspend_user(
|
||||
user_id: int,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
사용자 정지 (관리자 전용)
|
||||
|
||||
Args:
|
||||
user_id: 정지할 사용자 ID
|
||||
credentials: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Dict: 정지 결과
|
||||
"""
|
||||
try:
|
||||
# 토큰 검증 및 권한 확인
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
if payload['role'] not in ['system', 'admin']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자만 사용자를 정지할 수 있습니다"
|
||||
)
|
||||
|
||||
# 자기 자신 정지 방지
|
||||
if payload['user_id'] == user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="자기 자신은 정지할 수 없습니다"
|
||||
)
|
||||
|
||||
# 사용자 정지
|
||||
from sqlalchemy import text
|
||||
update_query = text("""
|
||||
UPDATE users
|
||||
SET status = 'suspended',
|
||||
is_active = FALSE,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = :user_id AND status = 'active'
|
||||
RETURNING user_id, username, name, status
|
||||
""")
|
||||
|
||||
result = db.execute(update_query, {"user_id": user_id}).fetchone()
|
||||
db.commit()
|
||||
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="사용자를 찾을 수 없거나 이미 정지된 상태입니다"
|
||||
)
|
||||
|
||||
logger.info(f"User {result.username} suspended by {payload['username']}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"{result.name} 사용자가 정지되었습니다",
|
||||
"user": {
|
||||
"user_id": result.user_id,
|
||||
"username": result.username,
|
||||
"name": result.name,
|
||||
"status": result.status
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to suspend user {user_id}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"사용자 정지 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}/reactivate")
|
||||
async def reactivate_user(
|
||||
user_id: int,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
사용자 재활성화 (관리자 전용)
|
||||
|
||||
Args:
|
||||
user_id: 재활성화할 사용자 ID
|
||||
credentials: JWT 토큰
|
||||
db: 데이터베이스 세션
|
||||
|
||||
Returns:
|
||||
Dict: 재활성화 결과
|
||||
"""
|
||||
try:
|
||||
# 토큰 검증 및 권한 확인
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
if payload['role'] not in ['system', 'admin']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="관리자만 사용자를 재활성화할 수 있습니다"
|
||||
)
|
||||
|
||||
# 사용자 재활성화
|
||||
from sqlalchemy import text
|
||||
update_query = text("""
|
||||
UPDATE users
|
||||
SET status = 'active',
|
||||
is_active = TRUE,
|
||||
failed_login_attempts = 0,
|
||||
locked_until = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = :user_id AND status = 'suspended'
|
||||
RETURNING user_id, username, name, status
|
||||
""")
|
||||
|
||||
result = db.execute(update_query, {"user_id": user_id}).fetchone()
|
||||
db.commit()
|
||||
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="사용자를 찾을 수 없거나 정지 상태가 아닙니다"
|
||||
)
|
||||
|
||||
logger.info(f"User {result.username} reactivated by {payload['username']}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"{result.name} 사용자가 재활성화되었습니다",
|
||||
"user": {
|
||||
"user_id": result.user_id,
|
||||
"username": result.username,
|
||||
"name": result.name,
|
||||
"status": result.status
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to reactivate user {user_id}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"사용자 재활성화 중 오류가 발생했습니다: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
@@ -391,10 +609,11 @@ async def delete_user(
|
||||
try:
|
||||
# 토큰 검증 및 권한 확인
|
||||
payload = jwt_service.verify_access_token(credentials.credentials)
|
||||
if payload['role'] != 'system':
|
||||
# admin role도 사용자 삭제 가능하도록 수정
|
||||
if payload['role'] not in ['system', 'admin']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="사용자 삭제는 시스템 관리자만 가능합니다"
|
||||
detail="사용자 삭제는 관리자만 가능합니다"
|
||||
)
|
||||
|
||||
# 자기 자신 삭제 방지
|
||||
@@ -404,7 +623,30 @@ async def delete_user(
|
||||
detail="자기 자신은 삭제할 수 없습니다"
|
||||
)
|
||||
|
||||
# 사용자 조회 및 삭제
|
||||
# BOM 데이터 존재 여부 확인
|
||||
from sqlalchemy import text
|
||||
|
||||
# files 테이블에서 uploaded_by가 이 사용자인 레코드 확인
|
||||
check_files = text("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM files
|
||||
WHERE uploaded_by = :user_id
|
||||
""")
|
||||
files_result = db.execute(check_files, {"user_id": user_id}).fetchone()
|
||||
has_files = files_result.count > 0 if files_result else False
|
||||
|
||||
# user_requirements 테이블 확인
|
||||
check_requirements = text("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM user_requirements
|
||||
WHERE created_by = :user_id
|
||||
""")
|
||||
requirements_result = db.execute(check_requirements, {"user_id": user_id}).fetchone()
|
||||
has_requirements = requirements_result.count > 0 if requirements_result else False
|
||||
|
||||
has_bom_data = has_files or has_requirements
|
||||
|
||||
# 사용자 조회
|
||||
user_repo = UserRepository(db)
|
||||
user = user_repo.find_by_id(user_id)
|
||||
|
||||
@@ -414,15 +656,39 @@ async def delete_user(
|
||||
detail="해당 사용자를 찾을 수 없습니다"
|
||||
)
|
||||
|
||||
user_repo.delete_user(user)
|
||||
|
||||
logger.info(f"User deleted by admin: {user.username} (deleted by: {payload['username']})")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '사용자가 삭제되었습니다',
|
||||
'deleted_user_id': user_id
|
||||
}
|
||||
if has_bom_data:
|
||||
# BOM 데이터가 있으면 소프트 삭제 (status='deleted')
|
||||
soft_delete = text("""
|
||||
UPDATE users
|
||||
SET status = 'deleted',
|
||||
is_active = FALSE,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = :user_id
|
||||
RETURNING username, name
|
||||
""")
|
||||
result = db.execute(soft_delete, {"user_id": user_id}).fetchone()
|
||||
db.commit()
|
||||
|
||||
logger.info(f"User soft-deleted (has BOM data): {result.username} (deleted by: {payload['username']})")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'{result.name} 사용자가 비활성화되었습니다 (BOM 데이터 보존)',
|
||||
'soft_deleted': True,
|
||||
'deleted_user_id': user_id
|
||||
}
|
||||
else:
|
||||
# BOM 데이터가 없으면 완전 삭제
|
||||
user_repo.delete_user(user)
|
||||
|
||||
logger.info(f"User hard-deleted (no BOM data): {user.username} (deleted by: {payload['username']})")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '사용자가 완전히 삭제되었습니다',
|
||||
'soft_deleted': False,
|
||||
'deleted_user_id': user_id
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -62,14 +62,38 @@ class AuthService:
|
||||
message="아이디 또는 비밀번호가 올바르지 않습니다"
|
||||
)
|
||||
|
||||
# 계정 활성화 상태 확인
|
||||
if not user.is_active:
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled')
|
||||
logger.warning(f"Login failed - account disabled: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="비활성화된 계정입니다. 관리자에게 문의하세요"
|
||||
)
|
||||
# 계정 상태 확인 (새로운 status 체계)
|
||||
if hasattr(user, 'status'):
|
||||
if user.status == 'pending':
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'pending_account')
|
||||
logger.warning(f"Login failed - pending account: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="계정 승인 대기 중입니다. 관리자 승인 후 이용 가능합니다"
|
||||
)
|
||||
elif user.status == 'suspended':
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'suspended_account')
|
||||
logger.warning(f"Login failed - suspended account: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="계정이 정지되었습니다. 관리자에게 문의하세요"
|
||||
)
|
||||
elif user.status == 'deleted':
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'deleted_account')
|
||||
logger.warning(f"Login failed - deleted account: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="삭제된 계정입니다"
|
||||
)
|
||||
else:
|
||||
# 하위 호환성: status 필드가 없으면 is_active 사용
|
||||
if not user.is_active:
|
||||
await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled')
|
||||
logger.warning(f"Login failed - account disabled: {username}")
|
||||
raise TKMPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
message="비활성화된 계정입니다. 관리자에게 문의하세요"
|
||||
)
|
||||
|
||||
# 계정 잠금 상태 확인
|
||||
if user.is_locked():
|
||||
|
||||
@@ -32,7 +32,8 @@ class User(Base):
|
||||
access_level = Column(String(20), default='worker', nullable=False) # 호환성 유지
|
||||
|
||||
# 계정 상태 관리
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False) # DEPRECATED: Use status instead
|
||||
status = Column(String(20), default='active', nullable=False) # pending, active, suspended, deleted
|
||||
failed_login_attempts = Column(Integer, default=0)
|
||||
locked_until = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -302,9 +303,15 @@ class UserRepository:
|
||||
raise
|
||||
|
||||
def get_all_users(self, skip: int = 0, limit: int = 100) -> List[User]:
|
||||
"""모든 사용자 조회"""
|
||||
"""활성 사용자만 조회 (status='active')"""
|
||||
try:
|
||||
return self.db.query(User).offset(skip).limit(limit).all()
|
||||
# status 필드가 있으면 status='active', 없으면 is_active=True (하위 호환성)
|
||||
users = self.db.query(User)
|
||||
if hasattr(User, 'status'):
|
||||
users = users.filter(User.status == 'active')
|
||||
else:
|
||||
users = users.filter(User.is_active == True)
|
||||
return users.offset(skip).limit(limit).all()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get all users: {str(e)}")
|
||||
return []
|
||||
|
||||
@@ -83,7 +83,8 @@ async def signup_request(
|
||||
'position': signup_data.position,
|
||||
'phone': signup_data.phone,
|
||||
'role': 'user',
|
||||
'is_active': False # 비활성 상태로 승인 대기 표시
|
||||
'is_active': False, # 하위 호환성
|
||||
'status': 'pending' # 새로운 status 체계: 승인 대기
|
||||
})
|
||||
|
||||
# 가입 사유 저장 (notes 컬럼 활용)
|
||||
@@ -130,13 +131,13 @@ async def get_signup_requests(
|
||||
detail="관리자만 접근 가능합니다"
|
||||
)
|
||||
|
||||
# 승인 대기 중인 사용자 조회 (is_active=False인 사용자)
|
||||
# 승인 대기 중인 사용자 조회 (status='pending'인 사용자)
|
||||
query = text("""
|
||||
SELECT
|
||||
user_id as id, username, name, email, department, position,
|
||||
phone, notes, created_at
|
||||
user_id, username, name, email, department, position,
|
||||
phone, created_at, role, is_active, status
|
||||
FROM users
|
||||
WHERE is_active = FALSE
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
|
||||
@@ -145,15 +146,18 @@ async def get_signup_requests(
|
||||
pending_users = []
|
||||
for row in results:
|
||||
pending_users.append({
|
||||
"id": row.id,
|
||||
"user_id": row.user_id,
|
||||
"id": row.user_id, # 호환성을 위해 둘 다 제공
|
||||
"username": row.username,
|
||||
"name": row.name,
|
||||
"email": row.email,
|
||||
"department": row.department,
|
||||
"position": row.position,
|
||||
"phone": row.phone,
|
||||
"reason": row.notes,
|
||||
"requested_at": row.created_at.isoformat() if row.created_at else None
|
||||
"role": row.role,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"requested_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"is_active": row.is_active
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -201,9 +205,10 @@ async def approve_signup(
|
||||
update_query = text("""
|
||||
UPDATE users
|
||||
SET is_active = TRUE,
|
||||
status = 'active',
|
||||
access_level = :access_level,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = :user_id AND is_active = FALSE
|
||||
WHERE user_id = :user_id AND status = 'pending'
|
||||
RETURNING user_id as id, username, name
|
||||
""")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user