Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- 파이프 수량 계산 로직 수정 (단관 개수가 아닌 실제 길이 기반 계산) - UI 전면 개편 (DevonThink 스타일의 간결하고 세련된 디자인) - 자재별 그룹핑 로직 개선: * 플랜지: 동일 사양별 그룹핑, WN 스케줄 표시, ORIFICE 풀네임 표시 * 피팅: 상세 타입 표시 (니플 길이, 엘보 각도/연결, 티 타입, 리듀서 타입 등) * 밸브: 동일 사양별 그룹핑, 타입/연결방식/압력 표시 * 볼트: 크기/재질/길이별 그룹핑 (8SET → 개별 집계) * 가스켓: 동일 사양별 그룹핑, 재질/상세내역/두께 분리 표시 * UNKNOWN: 원본 설명 전체 표시, 동일 항목 그룹핑 - 전체 카테고리 버튼 제거 (표시 복잡도 감소) - 카테고리별 동적 컬럼 헤더 및 레이아웃 적용
363 lines
11 KiB
Python
363 lines
11 KiB
Python
"""
|
|
사용자 활동 로그 서비스
|
|
모든 업무 활동을 추적하고 기록하는 서비스
|
|
"""
|
|
|
|
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
|
|
)
|