feat: 자재 관리 페이지 대규모 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산) - UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인) - 자재별 그룹핑 로직 개선: * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시 * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등) * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시 * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계) * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시 * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑 - 전체 카테고리 버튼 제거 (표시 복잡도 감소) - 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
This commit is contained in:
362
backend/app/services/activity_logger.py
Normal file
362
backend/app/services/activity_logger.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
사용자 활동 로그 서비스
|
||||
모든 업무 활동을 추적하고 기록하는 서비스
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import Request
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ActivityLogger:
|
||||
"""사용자 활동 로그 관리 클래스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def log_activity(
|
||||
self,
|
||||
username: str,
|
||||
activity_type: str,
|
||||
activity_description: str,
|
||||
target_id: Optional[int] = None,
|
||||
target_type: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""
|
||||
사용자 활동 로그 기록
|
||||
|
||||
Args:
|
||||
username: 사용자명 (필수)
|
||||
activity_type: 활동 유형 (FILE_UPLOAD, PROJECT_CREATE 등)
|
||||
activity_description: 활동 설명
|
||||
target_id: 대상 ID (파일, 프로젝트 등)
|
||||
target_type: 대상 유형 (FILE, PROJECT 등)
|
||||
user_id: 사용자 ID
|
||||
ip_address: IP 주소
|
||||
user_agent: 브라우저 정보
|
||||
metadata: 추가 메타데이터
|
||||
|
||||
Returns:
|
||||
int: 생성된 로그 ID
|
||||
"""
|
||||
try:
|
||||
insert_query = text("""
|
||||
INSERT INTO user_activity_logs (
|
||||
user_id, username, activity_type, activity_description,
|
||||
target_id, target_type, ip_address, user_agent, metadata
|
||||
) VALUES (
|
||||
:user_id, :username, :activity_type, :activity_description,
|
||||
:target_id, :target_type, :ip_address, :user_agent, :metadata
|
||||
) RETURNING id
|
||||
""")
|
||||
|
||||
result = self.db.execute(insert_query, {
|
||||
'user_id': user_id,
|
||||
'username': username,
|
||||
'activity_type': activity_type,
|
||||
'activity_description': activity_description,
|
||||
'target_id': target_id,
|
||||
'target_type': target_type,
|
||||
'ip_address': ip_address,
|
||||
'user_agent': user_agent,
|
||||
'metadata': json.dumps(metadata) if metadata else None
|
||||
})
|
||||
|
||||
log_id = result.fetchone()[0]
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Activity logged: {username} - {activity_type} - {activity_description}")
|
||||
return log_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log activity: {str(e)}")
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
def log_file_upload(
|
||||
self,
|
||||
username: str,
|
||||
file_id: int,
|
||||
filename: str,
|
||||
file_size: int,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""파일 업로드 활동 로그"""
|
||||
metadata = {
|
||||
'filename': filename,
|
||||
'file_size': file_size,
|
||||
'job_no': job_no,
|
||||
'revision': revision,
|
||||
'upload_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='FILE_UPLOAD',
|
||||
activity_description=f'BOM 파일 업로드: {filename} (Job: {job_no}, Rev: {revision})',
|
||||
target_id=file_id,
|
||||
target_type='FILE',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def log_project_create(
|
||||
self,
|
||||
username: str,
|
||||
project_id: int,
|
||||
project_name: str,
|
||||
job_no: str,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""프로젝트 생성 활동 로그"""
|
||||
metadata = {
|
||||
'project_name': project_name,
|
||||
'job_no': job_no,
|
||||
'create_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='PROJECT_CREATE',
|
||||
activity_description=f'프로젝트 생성: {project_name} ({job_no})',
|
||||
target_id=project_id,
|
||||
target_type='PROJECT',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def log_material_classify(
|
||||
self,
|
||||
username: str,
|
||||
file_id: int,
|
||||
classified_count: int,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""자재 분류 활동 로그"""
|
||||
metadata = {
|
||||
'classified_count': classified_count,
|
||||
'job_no': job_no,
|
||||
'revision': revision,
|
||||
'classify_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='MATERIAL_CLASSIFY',
|
||||
activity_description=f'자재 분류 완료: {classified_count}개 자재 (Job: {job_no}, Rev: {revision})',
|
||||
target_id=file_id,
|
||||
target_type='FILE',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def log_purchase_confirm(
|
||||
self,
|
||||
username: str,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
confirmed_count: int,
|
||||
total_amount: Optional[float] = None,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""구매 확정 활동 로그"""
|
||||
metadata = {
|
||||
'job_no': job_no,
|
||||
'revision': revision,
|
||||
'confirmed_count': confirmed_count,
|
||||
'total_amount': total_amount,
|
||||
'confirm_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='PURCHASE_CONFIRM',
|
||||
activity_description=f'구매 확정: {confirmed_count}개 품목 (Job: {job_no}, Rev: {revision})',
|
||||
target_id=None, # 구매는 특정 ID가 없음
|
||||
target_type='PURCHASE',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def get_user_activities(
|
||||
self,
|
||||
username: str,
|
||||
activity_type: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> list:
|
||||
"""사용자 활동 이력 조회"""
|
||||
try:
|
||||
where_clause = "WHERE username = :username"
|
||||
params = {'username': username}
|
||||
|
||||
if activity_type:
|
||||
where_clause += " AND activity_type = :activity_type"
|
||||
params['activity_type'] = activity_type
|
||||
|
||||
query = text(f"""
|
||||
SELECT
|
||||
id, activity_type, activity_description,
|
||||
target_id, target_type, metadata, created_at
|
||||
FROM user_activity_logs
|
||||
{where_clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
|
||||
params.update({'limit': limit, 'offset': offset})
|
||||
result = self.db.execute(query, params)
|
||||
|
||||
activities = []
|
||||
for row in result.fetchall():
|
||||
activity = {
|
||||
'id': row[0],
|
||||
'activity_type': row[1],
|
||||
'activity_description': row[2],
|
||||
'target_id': row[3],
|
||||
'target_type': row[4],
|
||||
'metadata': json.loads(row[5]) if row[5] else {},
|
||||
'created_at': row[6].isoformat() if row[6] else None
|
||||
}
|
||||
activities.append(activity)
|
||||
|
||||
return activities
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user activities: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_recent_activities(
|
||||
self,
|
||||
days: int = 7,
|
||||
limit: int = 100
|
||||
) -> list:
|
||||
"""최근 활동 조회 (전체 사용자)"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
username, activity_type, activity_description,
|
||||
target_id, target_type, created_at
|
||||
FROM user_activity_logs
|
||||
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '%s days'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit
|
||||
""" % days)
|
||||
|
||||
result = self.db.execute(query, {'limit': limit})
|
||||
|
||||
activities = []
|
||||
for row in result.fetchall():
|
||||
activity = {
|
||||
'username': row[0],
|
||||
'activity_type': row[1],
|
||||
'activity_description': row[2],
|
||||
'target_id': row[3],
|
||||
'target_type': row[4],
|
||||
'created_at': row[5].isoformat() if row[5] else None
|
||||
}
|
||||
activities.append(activity)
|
||||
|
||||
return activities
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get recent activities: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def get_client_info(request: Request) -> tuple:
|
||||
"""
|
||||
요청에서 클라이언트 정보 추출
|
||||
|
||||
Args:
|
||||
request: FastAPI Request 객체
|
||||
|
||||
Returns:
|
||||
tuple: (ip_address, user_agent)
|
||||
"""
|
||||
# IP 주소 추출 (프록시 고려)
|
||||
ip_address = (
|
||||
request.headers.get('x-forwarded-for', '').split(',')[0].strip() or
|
||||
request.headers.get('x-real-ip', '') or
|
||||
request.client.host if request.client else 'unknown'
|
||||
)
|
||||
|
||||
# User-Agent 추출
|
||||
user_agent = request.headers.get('user-agent', 'unknown')
|
||||
|
||||
return ip_address, user_agent
|
||||
|
||||
|
||||
def log_activity_from_request(
|
||||
db: Session,
|
||||
request: Request,
|
||||
username: str,
|
||||
activity_type: str,
|
||||
activity_description: str,
|
||||
target_id: Optional[int] = None,
|
||||
target_type: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""
|
||||
요청 정보를 포함한 활동 로그 기록 (편의 함수)
|
||||
|
||||
Args:
|
||||
db: 데이터베이스 세션
|
||||
request: FastAPI Request 객체
|
||||
username: 사용자명
|
||||
activity_type: 활동 유형
|
||||
activity_description: 활동 설명
|
||||
target_id: 대상 ID
|
||||
target_type: 대상 유형
|
||||
user_id: 사용자 ID
|
||||
metadata: 추가 메타데이터
|
||||
|
||||
Returns:
|
||||
int: 생성된 로그 ID
|
||||
"""
|
||||
ip_address, user_agent = get_client_info(request)
|
||||
|
||||
activity_logger = ActivityLogger(db)
|
||||
return activity_logger.log_activity(
|
||||
username=username,
|
||||
activity_type=activity_type,
|
||||
activity_description=activity_description,
|
||||
target_id=target_id,
|
||||
target_type=target_type,
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
Reference in New Issue
Block a user