feat: tkuser 통합 관리 서비스 + 전체 시스템 SSO 쿠키 인증 통합
- tkuser 서비스 신규 추가 (API + Web) - 사용자/권한/프로젝트/부서/작업자/작업장/설비/작업/휴가 통합 관리 - 작업장 탭: 공장→작업장 드릴다운 네비게이션 + 구역지도 클릭 연동 - 작업 탭: 공정(work_types)→작업(tasks) 계층 관리 - 휴가 탭: 유형 관리 + 연차 배정(근로기준법 자동계산) - 전 시스템 SSO 쿠키 인증으로 통합 (.technicalkorea.net 공유) - System 2: 작업 이슈 리포트 기능 강화 - System 3: tkuser API 연동, 페이지 권한 체계 적용 - docker-compose에 tkuser-api, tkuser-web 서비스 추가 - ARCHITECTURE.md, DEPLOYMENT.md 문서 작성 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,18 @@ from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
import os
|
||||
from typing import Generator
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://mproject:mproject2024@localhost:5432/mproject")
|
||||
# DB 설정 — 개별 환경변수에서 읽어서 URL 구성 (비밀번호 특수문자 처리)
|
||||
DB_HOST = os.getenv("DB_HOST", "mariadb")
|
||||
DB_PORT = os.getenv("DB_PORT", "3306")
|
||||
DB_USER = os.getenv("DB_USER", "hyungi_user")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD", "password")
|
||||
DB_NAME = os.getenv("DB_NAME", "hyungi")
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
DATABASE_URL = f"mysql+pymysql://{DB_USER}:{quote_plus(DB_PASSWORD)}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4"
|
||||
|
||||
engine = create_engine(DATABASE_URL, pool_recycle=3600)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, BigInteger, String, DateTime, Float, Boolean, Text, ForeignKey, Enum, Index
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import Column, Integer, BigInteger, String, DateTime, Float, Boolean, Text, ForeignKey, Enum, Index, JSON
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone, timedelta
|
||||
@@ -15,8 +14,11 @@ def get_kst_now():
|
||||
Base = declarative_base()
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
admin = "admin" # 관리자
|
||||
user = "user" # 일반 사용자
|
||||
system = "system" # 시스템 관리자
|
||||
admin = "admin" # 관리자
|
||||
support_team = "support_team" # 지원팀
|
||||
leader = "leader" # 리더
|
||||
user = "user" # 일반 사용자
|
||||
|
||||
class IssueStatus(str, enum.Enum):
|
||||
new = "new"
|
||||
@@ -51,77 +53,76 @@ class DepartmentType(str, enum.Enum):
|
||||
sales = "sales" # 영업
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
full_name = Column(String)
|
||||
__tablename__ = "sso_users"
|
||||
|
||||
# Column mapping: Python attr → DB column
|
||||
id = Column("user_id", Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column("password_hash", String(255), nullable=False)
|
||||
full_name = Column("name", String(100))
|
||||
role = Column(Enum(UserRole), default=UserRole.user)
|
||||
department = Column(Enum(DepartmentType)) # 부서 정보 추가
|
||||
department = Column(String(50))
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
|
||||
# Relationships
|
||||
issues = relationship("Issue", back_populates="reporter", foreign_keys="Issue.reporter_id")
|
||||
reviewed_issues = relationship("Issue", foreign_keys="Issue.reviewed_by_id")
|
||||
daily_works = relationship("DailyWork", back_populates="created_by")
|
||||
projects = relationship("Project", back_populates="created_by")
|
||||
page_permissions = relationship("UserPagePermission", back_populates="user", foreign_keys="UserPagePermission.user_id")
|
||||
|
||||
class UserPagePermission(Base):
|
||||
__tablename__ = "user_page_permissions"
|
||||
|
||||
__tablename__ = "qc_user_page_permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("sso_users.user_id", ondelete="CASCADE"), nullable=False)
|
||||
page_name = Column(String(50), nullable=False)
|
||||
can_access = Column(Boolean, default=False)
|
||||
granted_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
granted_by_id = Column(Integer, ForeignKey("sso_users.user_id"))
|
||||
granted_at = Column(DateTime, default=get_kst_now)
|
||||
notes = Column(Text)
|
||||
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="page_permissions", foreign_keys=[user_id])
|
||||
granted_by = relationship("User", foreign_keys=[granted_by_id], post_update=True)
|
||||
|
||||
# Unique constraint
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_user_page_permissions_user_id', 'user_id'),
|
||||
Index('idx_user_page_permissions_page_name', 'page_name'),
|
||||
Index('idx_qc_user_page_perm_user_id', 'user_id'),
|
||||
Index('idx_qc_user_page_perm_page_name', 'page_name'),
|
||||
)
|
||||
|
||||
class Issue(Base):
|
||||
__tablename__ = "issues"
|
||||
|
||||
__tablename__ = "qc_issues"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
photo_path = Column(String)
|
||||
photo_path2 = Column(String)
|
||||
photo_path3 = Column(String)
|
||||
photo_path4 = Column(String)
|
||||
photo_path5 = Column(String)
|
||||
photo_path = Column(String(500))
|
||||
photo_path2 = Column(String(500))
|
||||
photo_path3 = Column(String(500))
|
||||
photo_path4 = Column(String(500))
|
||||
photo_path5 = Column(String(500))
|
||||
category = Column(Enum(IssueCategory), nullable=False)
|
||||
description = Column(Text, nullable=False)
|
||||
status = Column(Enum(IssueStatus), default=IssueStatus.new)
|
||||
reporter_id = Column(Integer, ForeignKey("users.id"))
|
||||
project_id = Column(BigInteger, ForeignKey("projects.id"))
|
||||
reporter_id = Column(Integer, ForeignKey("sso_users.user_id"))
|
||||
project_id = Column(Integer) # FK 제거 — projects는 tkuser에서 관리
|
||||
report_date = Column(DateTime, default=get_kst_now)
|
||||
work_hours = Column(Float, default=0)
|
||||
detail_notes = Column(Text)
|
||||
|
||||
|
||||
# 수신함 워크플로우 관련 컬럼들
|
||||
review_status = Column(Enum(ReviewStatus), default=ReviewStatus.pending_review)
|
||||
disposal_reason = Column(Enum(DisposalReasonType))
|
||||
custom_disposal_reason = Column(Text)
|
||||
disposed_at = Column(DateTime)
|
||||
reviewed_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
reviewed_by_id = Column(Integer, ForeignKey("sso_users.user_id"))
|
||||
reviewed_at = Column(DateTime)
|
||||
original_data = Column(JSONB) # 원본 데이터 보존
|
||||
modification_log = Column(JSONB, default=lambda: []) # 수정 이력
|
||||
|
||||
original_data = Column(JSON)
|
||||
modification_log = Column(JSON, default=lambda: [])
|
||||
|
||||
# 중복 신고 추적 시스템
|
||||
duplicate_of_issue_id = Column(Integer, ForeignKey("issues.id")) # 중복 대상 이슈 ID
|
||||
duplicate_reporters = Column(JSONB, default=lambda: []) # 중복 신고자 목록
|
||||
|
||||
duplicate_of_issue_id = Column(Integer, ForeignKey("qc_issues.id"))
|
||||
duplicate_reporters = Column(JSON, default=lambda: [])
|
||||
|
||||
# 관리함에서 사용할 추가 필드들
|
||||
solution = Column(Text) # 해결방안 (관리함에서 입력)
|
||||
responsible_department = Column(Enum(DepartmentType)) # 담당부서
|
||||
@@ -133,16 +134,16 @@ class Issue(Base):
|
||||
project_sequence_no = Column(Integer) # 프로젝트별 순번 (No)
|
||||
final_description = Column(Text) # 최종 내용 (수정본 또는 원본)
|
||||
final_category = Column(Enum(IssueCategory)) # 최종 카테고리 (수정본 또는 원본)
|
||||
|
||||
|
||||
# 추가 정보 필드들 (관리함에서 기록용)
|
||||
responsible_person_detail = Column(String(200)) # 해당자 상세 정보
|
||||
cause_detail = Column(Text) # 원인 상세 정보
|
||||
additional_info_updated_at = Column(DateTime) # 추가 정보 입력 시간
|
||||
additional_info_updated_by_id = Column(Integer, ForeignKey("users.id")) # 추가 정보 입력자
|
||||
|
||||
additional_info_updated_by_id = Column(Integer, ForeignKey("sso_users.user_id")) # 추가 정보 입력자
|
||||
|
||||
# 완료 신청 관련 필드들
|
||||
completion_requested_at = Column(DateTime) # 완료 신청 시간
|
||||
completion_requested_by_id = Column(Integer, ForeignKey("users.id")) # 완료 신청자
|
||||
completion_requested_by_id = Column(Integer, ForeignKey("sso_users.user_id")) # 완료 신청자
|
||||
completion_photo_path = Column(String(500)) # 완료 사진 1
|
||||
completion_photo_path2 = Column(String(500)) # 완료 사진 2
|
||||
completion_photo_path3 = Column(String(500)) # 완료 사진 3
|
||||
@@ -152,7 +153,7 @@ class Issue(Base):
|
||||
|
||||
# 완료 반려 관련 필드들
|
||||
completion_rejected_at = Column(DateTime) # 완료 반려 시간
|
||||
completion_rejected_by_id = Column(Integer, ForeignKey("users.id")) # 완료 반려자
|
||||
completion_rejected_by_id = Column(Integer, ForeignKey("sso_users.user_id")) # 완료 반려자
|
||||
completion_rejection_reason = Column(Text) # 완료 반려 사유
|
||||
|
||||
# 일일보고서 추출 이력
|
||||
@@ -162,26 +163,29 @@ class Issue(Base):
|
||||
# Relationships
|
||||
reporter = relationship("User", back_populates="issues", foreign_keys=[reporter_id])
|
||||
reviewer = relationship("User", foreign_keys=[reviewed_by_id], overlaps="reviewed_issues")
|
||||
project = relationship("Project", back_populates="issues")
|
||||
project = relationship("Project", back_populates="issues",
|
||||
primaryjoin="Issue.project_id == Project.id",
|
||||
foreign_keys=[project_id])
|
||||
duplicate_of = relationship("Issue", remote_side=[id], foreign_keys=[duplicate_of_issue_id])
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
job_no = Column(String, unique=True, nullable=False, index=True)
|
||||
project_name = Column(String, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
# Column mapping: Python attr id → DB column project_id
|
||||
id = Column("project_id", Integer, primary_key=True, index=True)
|
||||
job_no = Column(String(50), unique=True, nullable=False, index=True)
|
||||
project_name = Column(String(255), nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
# Relationships
|
||||
created_by = relationship("User", back_populates="projects")
|
||||
issues = relationship("Issue", back_populates="project")
|
||||
issues = relationship("Issue", back_populates="project",
|
||||
primaryjoin="Project.id == Issue.project_id",
|
||||
foreign_keys="[Issue.project_id]")
|
||||
|
||||
class DailyWork(Base):
|
||||
__tablename__ = "daily_works"
|
||||
|
||||
__tablename__ = "qc_daily_works"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(DateTime, nullable=False, index=True)
|
||||
worker_count = Column(Integer, nullable=False)
|
||||
@@ -190,20 +194,20 @@ class DailyWork(Base):
|
||||
overtime_hours = Column(Float, default=0)
|
||||
overtime_total = Column(Float, default=0)
|
||||
total_hours = Column(Float, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
created_by_id = Column(Integer, ForeignKey("sso_users.user_id"))
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
|
||||
# Relationships
|
||||
created_by = relationship("User", back_populates="daily_works")
|
||||
|
||||
class ProjectDailyWork(Base):
|
||||
__tablename__ = "project_daily_works"
|
||||
__tablename__ = "qc_project_daily_works"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(DateTime, nullable=False, index=True)
|
||||
project_id = Column(BigInteger, ForeignKey("projects.id"), nullable=False)
|
||||
project_id = Column(Integer, ForeignKey("projects.project_id"), nullable=False)
|
||||
hours = Column(Float, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"))
|
||||
created_by_id = Column(Integer, ForeignKey("sso_users.user_id"))
|
||||
created_at = Column(DateTime, default=get_kst_now)
|
||||
|
||||
# Relationships
|
||||
@@ -211,13 +215,13 @@ class ProjectDailyWork(Base):
|
||||
created_by = relationship("User")
|
||||
|
||||
class DeletionLog(Base):
|
||||
__tablename__ = "deletion_logs"
|
||||
__tablename__ = "qc_deletion_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_type = Column(String(50), nullable=False) # 'issue', 'project', 'daily_work' 등
|
||||
entity_id = Column(Integer, nullable=False) # 삭제된 엔티티의 ID
|
||||
entity_data = Column(JSONB, nullable=False) # 삭제된 데이터 전체 (JSON)
|
||||
deleted_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
entity_data = Column(JSON, nullable=False) # 삭제된 데이터 전체 (JSON)
|
||||
deleted_by_id = Column(Integer, ForeignKey("sso_users.user_id"), nullable=False)
|
||||
deleted_at = Column(DateTime, default=get_kst_now, nullable=False)
|
||||
reason = Column(Text) # 삭제 사유 (선택사항)
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
class UserRole(str, Enum):
|
||||
system = "system"
|
||||
admin = "admin"
|
||||
support_team = "support_team"
|
||||
leader = "leader"
|
||||
user = "user"
|
||||
|
||||
class IssueStatus(str, Enum):
|
||||
@@ -285,12 +288,11 @@ class ProjectUpdate(BaseModel):
|
||||
|
||||
class Project(ProjectBase):
|
||||
id: int
|
||||
created_by_id: int
|
||||
created_by: User
|
||||
created_by_id: Optional[int] = None
|
||||
created_by: Optional[User] = None
|
||||
created_at: datetime
|
||||
is_active: bool
|
||||
# issues: Optional[List['Issue']] = None # 순환 참조 방지를 위해 제거
|
||||
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ from database.models import Base
|
||||
from routers import auth, issues, daily_work, reports, projects, page_permissions, inbox, management
|
||||
from services.auth_service import create_admin_user
|
||||
|
||||
# 데이터베이스 테이블 생성
|
||||
# 메타데이터 캐시 클리어
|
||||
Base.metadata.clear()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
# 데이터베이스 테이블 생성 (sso_users, projects는 이미 존재하므로 제외)
|
||||
tables_to_create = [
|
||||
table for name, table in Base.metadata.tables.items()
|
||||
if name not in ("sso_users", "projects")
|
||||
]
|
||||
Base.metadata.create_all(bind=engine, tables=tables_to_create)
|
||||
|
||||
# FastAPI 앱 생성
|
||||
app = FastAPI(
|
||||
|
||||
@@ -5,7 +5,7 @@ python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.1.2
|
||||
sqlalchemy==2.0.23
|
||||
psycopg2-binary==2.9.9
|
||||
pymysql==1.1.0
|
||||
alembic==1.12.1
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
@@ -13,3 +13,4 @@ pillow==10.1.0
|
||||
pillow-heif==0.13.0
|
||||
reportlab==4.0.7
|
||||
openpyxl==3.1.2
|
||||
httpx==0.27.0
|
||||
|
||||
@@ -35,7 +35,11 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
|
||||
sso_role = payload.get("role", "user")
|
||||
role_map = {
|
||||
"Admin": UserRole.admin,
|
||||
"System Admin": UserRole.admin,
|
||||
"System Admin": UserRole.system,
|
||||
"system": UserRole.system,
|
||||
"admin": UserRole.admin,
|
||||
"support_team": UserRole.support_team,
|
||||
"leader": UserRole.leader,
|
||||
}
|
||||
mapped_role = role_map.get(sso_role, UserRole.user)
|
||||
|
||||
@@ -50,7 +54,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
|
||||
return sso_user
|
||||
|
||||
async def get_current_admin(current_user: User = Depends(get_current_user)):
|
||||
if current_user.role != UserRole.admin:
|
||||
if current_user.role not in [UserRole.admin, UserRole.system]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import Issue, User, Project, ReviewStatus, DisposalReasonType
|
||||
from database.models import Issue, User, ReviewStatus, DisposalReasonType
|
||||
from utils.tkuser_client import get_token_from_request
|
||||
import utils.tkuser_client as tkuser_client
|
||||
from database.schemas import (
|
||||
InboxIssue, IssueDisposalRequest, IssueReviewRequest,
|
||||
IssueStatusUpdateRequest, ModificationLogEntry, ManagementUpdateRequest
|
||||
@@ -123,6 +125,7 @@ async def dispose_issue(
|
||||
async def review_issue(
|
||||
issue_id: int,
|
||||
review_request: IssueReviewRequest,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user), # 수신함 권한이 있는 사용자
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
@@ -157,9 +160,10 @@ async def review_issue(
|
||||
|
||||
# 프로젝트 변경
|
||||
if review_request.project_id is not None and review_request.project_id != issue.project_id:
|
||||
# 프로젝트 존재 확인
|
||||
# 프로젝트 존재 확인 (tkuser API)
|
||||
if review_request.project_id != 0: # 0은 프로젝트 없음을 의미
|
||||
project = db.query(Project).filter(Project.id == review_request.project_id).first()
|
||||
token = get_token_from_request(request)
|
||||
project = await tkuser_client.get_project(token, review_request.project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=400, detail="존재하지 않는 프로젝트입니다.")
|
||||
|
||||
@@ -264,12 +268,11 @@ async def update_issue_status(
|
||||
# 진행 중 또는 완료 상태로 변경 시 프로젝트별 순번 자동 할당
|
||||
if status_request.review_status in [ReviewStatus.in_progress, ReviewStatus.completed]:
|
||||
if not issue.project_sequence_no:
|
||||
from sqlalchemy import text
|
||||
result = db.execute(
|
||||
text("SELECT generate_project_sequence_no(:project_id)"),
|
||||
{"project_id": issue.project_id}
|
||||
)
|
||||
issue.project_sequence_no = result.scalar()
|
||||
from sqlalchemy import func
|
||||
max_seq = db.query(func.coalesce(func.max(Issue.project_sequence_no), 0)).filter(
|
||||
Issue.project_id == issue.project_id
|
||||
).scalar()
|
||||
issue.project_sequence_no = max_seq + 1
|
||||
|
||||
# 완료 사진 업로드 처리
|
||||
if status_request.completion_photo and status_request.review_status == ReviewStatus.completed:
|
||||
|
||||
@@ -259,9 +259,9 @@ async def get_issue_stats(
|
||||
query = query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
total = query.count()
|
||||
new = query.filter(Issue.status == IssueStatus.NEW).count()
|
||||
progress = query.filter(Issue.status == IssueStatus.PROGRESS).count()
|
||||
complete = query.filter(Issue.status == IssueStatus.COMPLETE).count()
|
||||
new = query.filter(Issue.status == IssueStatus.new).count()
|
||||
progress = query.filter(Issue.status == IssueStatus.progress).count()
|
||||
complete = query.filter(Issue.status == IssueStatus.complete).count()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from database.database import get_db
|
||||
from database.models import Project, User, UserRole
|
||||
from database.schemas import ProjectCreate, ProjectUpdate, Project as ProjectSchema
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from database.models import User, UserRole
|
||||
from database.schemas import ProjectCreate, ProjectUpdate
|
||||
from routers.auth import get_current_user
|
||||
from utils.tkuser_client import get_token_from_request
|
||||
import utils.tkuser_client as tkuser_client
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/projects",
|
||||
@@ -25,57 +24,36 @@ async def projects_options():
|
||||
"""OPTIONS preflight 요청 처리"""
|
||||
return {"message": "OK"}
|
||||
|
||||
@router.post("/", response_model=ProjectSchema)
|
||||
@router.post("/")
|
||||
async def create_project(
|
||||
project: ProjectCreate,
|
||||
db: Session = Depends(get_db),
|
||||
request: Request,
|
||||
current_user: User = Depends(check_admin_permission)
|
||||
):
|
||||
"""프로젝트 생성 (관리자만)"""
|
||||
# Job No. 중복 확인
|
||||
existing_project = db.query(Project).filter(Project.job_no == project.job_no).first()
|
||||
if existing_project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 존재하는 Job No.입니다."
|
||||
)
|
||||
|
||||
# 프로젝트 생성
|
||||
db_project = Project(
|
||||
job_no=project.job_no,
|
||||
project_name=project.project_name,
|
||||
created_by_id=current_user.id
|
||||
)
|
||||
|
||||
db.add(db_project)
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
|
||||
return db_project
|
||||
"""프로젝트 생성 (관리자만) - tkuser API로 프록시"""
|
||||
token = get_token_from_request(request)
|
||||
return await tkuser_client.create_project(token, project.dict())
|
||||
|
||||
@router.get("/", response_model=List[ProjectSchema])
|
||||
@router.get("/")
|
||||
async def get_projects(
|
||||
request: Request,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
active_only: bool = True,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""프로젝트 목록 조회"""
|
||||
query = db.query(Project)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(Project.is_active == True)
|
||||
|
||||
projects = query.offset(skip).limit(limit).all()
|
||||
return projects
|
||||
"""프로젝트 목록 조회 - tkuser API로 프록시"""
|
||||
token = get_token_from_request(request)
|
||||
projects = await tkuser_client.get_projects(token, active_only=active_only)
|
||||
return projects[skip:skip + limit]
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectSchema)
|
||||
@router.get("/{project_id}")
|
||||
async def get_project(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
request: Request,
|
||||
):
|
||||
"""특정 프로젝트 조회"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
"""특정 프로젝트 조회 - tkuser API로 프록시"""
|
||||
token = get_token_from_request(request)
|
||||
project = await tkuser_client.get_project(token, project_id)
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -83,47 +61,26 @@ async def get_project(
|
||||
)
|
||||
return project
|
||||
|
||||
@router.put("/{project_id}", response_model=ProjectSchema)
|
||||
@router.put("/{project_id}")
|
||||
async def update_project(
|
||||
project_id: int,
|
||||
project_update: ProjectUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
request: Request,
|
||||
current_user: User = Depends(check_admin_permission)
|
||||
):
|
||||
"""프로젝트 수정 (관리자만)"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="프로젝트를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 업데이트할 필드만 수정
|
||||
update_data = project_update.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(project, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
|
||||
return project
|
||||
"""프로젝트 수정 (관리자만) - tkuser API로 프록시"""
|
||||
token = get_token_from_request(request)
|
||||
return await tkuser_client.update_project(
|
||||
token, project_id, project_update.dict(exclude_unset=True)
|
||||
)
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
async def delete_project(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
request: Request,
|
||||
current_user: User = Depends(check_admin_permission)
|
||||
):
|
||||
"""프로젝트 삭제 (비활성화) (관리자만)"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="프로젝트를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 실제 삭제 대신 비활성화
|
||||
project.is_active = False
|
||||
db.commit()
|
||||
|
||||
"""프로젝트 삭제 (비활성화) (관리자만) - tkuser API로 프록시"""
|
||||
token = get_token_from_request(request)
|
||||
await tkuser_client.delete_project(token, project_id)
|
||||
return {"message": "프로젝트가 삭제되었습니다."}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_
|
||||
@@ -13,9 +13,11 @@ from openpyxl.drawing.image import Image as XLImage
|
||||
import os
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, Project, ReviewStatus
|
||||
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, ReviewStatus
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
from utils.tkuser_client import get_token_from_request
|
||||
import utils.tkuser_client as tkuser_client
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@@ -140,6 +142,7 @@ async def get_report_daily_works(
|
||||
@router.get("/daily-preview")
|
||||
async def preview_daily_report(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
@@ -149,8 +152,9 @@ async def preview_daily_report(
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
|
||||
|
||||
# 프로젝트 확인
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
# 프로젝트 확인 (tkuser API)
|
||||
token = get_token_from_request(request)
|
||||
project = await tkuser_client.get_project(token, project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
@@ -178,7 +182,7 @@ async def preview_daily_report(
|
||||
issues_data = [schemas.Issue.from_orm(issue) for issue in issues]
|
||||
|
||||
return {
|
||||
"project": schemas.Project.from_orm(project),
|
||||
"project": project,
|
||||
"stats": stats,
|
||||
"issues": issues_data,
|
||||
"total_issues": len(issues)
|
||||
@@ -186,31 +190,33 @@ async def preview_daily_report(
|
||||
|
||||
@router.post("/daily-export")
|
||||
async def export_daily_report(
|
||||
request: schemas.DailyReportRequest,
|
||||
report_req: schemas.DailyReportRequest,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""품질팀용 일일보고서 엑셀 내보내기"""
|
||||
|
||||
|
||||
# 권한 확인 (품질팀만 접근 가능)
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
|
||||
|
||||
# 프로젝트 확인
|
||||
project = db.query(Project).filter(Project.id == request.project_id).first()
|
||||
|
||||
# 프로젝트 확인 (tkuser API)
|
||||
token = get_token_from_request(request)
|
||||
project = await tkuser_client.get_project(token, report_req.project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
# 관리함 데이터 조회
|
||||
# 1. 진행 중인 항목 (모두 포함)
|
||||
in_progress_only = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.project_id == report_req.project_id,
|
||||
Issue.review_status == ReviewStatus.in_progress
|
||||
).all()
|
||||
|
||||
# 2. 완료된 항목 (모두 조회)
|
||||
all_completed = db.query(Issue).filter(
|
||||
Issue.project_id == request.project_id,
|
||||
Issue.project_id == report_req.project_id,
|
||||
Issue.review_status == ReviewStatus.completed
|
||||
).all()
|
||||
|
||||
@@ -307,7 +313,7 @@ async def export_daily_report(
|
||||
for ws, sheet_issues, sheet_title in sheets_data:
|
||||
# 제목 및 기본 정보
|
||||
ws.merge_cells('A1:L1')
|
||||
ws['A1'] = f"{project.project_name} - {sheet_title}"
|
||||
ws['A1'] = f"{project['project_name']} - {sheet_title}"
|
||||
ws['A1'].font = Font(bold=True, size=16)
|
||||
ws['A1'].alignment = center_alignment
|
||||
|
||||
@@ -725,7 +731,7 @@ async def export_daily_report(
|
||||
|
||||
# 파일명 생성
|
||||
today = date.today().strftime('%Y%m%d')
|
||||
filename = f"{project.project_name}_일일보고서_{today}.xlsx"
|
||||
filename = f"{project['project_name']}_일일보고서_{today}.xlsx"
|
||||
|
||||
# 한글 파일명을 위한 URL 인코딩
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -75,21 +75,11 @@ def authenticate_user(db: Session, username: str, password: str):
|
||||
return user
|
||||
|
||||
def create_admin_user(db: Session):
|
||||
"""초기 관리자 계정 생성"""
|
||||
"""관리자 계정 확인 (SSO에서 관리, 여기서는 조회만)"""
|
||||
admin_username = os.getenv("ADMIN_USERNAME", "hyungi")
|
||||
admin_password = os.getenv("ADMIN_PASSWORD", "djg3-jj34-X3Q3")
|
||||
|
||||
existing_admin = db.query(User).filter(User.username == admin_username).first()
|
||||
if not existing_admin:
|
||||
admin_user = User(
|
||||
username=admin_username,
|
||||
hashed_password=get_password_hash(admin_password),
|
||||
full_name="관리자",
|
||||
role=UserRole.admin,
|
||||
is_active=True
|
||||
)
|
||||
db.add(admin_user)
|
||||
db.commit()
|
||||
print(f"관리자 계정 생성됨: {admin_username}")
|
||||
if existing_admin:
|
||||
print(f"관리자 계정 확인됨: {admin_username} (role: {existing_admin.role.value})")
|
||||
else:
|
||||
print(f"관리자 계정이 이미 존재함: {admin_username}")
|
||||
print(f"경고: 관리자 계정이 sso_users에 없습니다: {admin_username}")
|
||||
|
||||
0
system3-nonconformance/api/utils/__init__.py
Normal file
0
system3-nonconformance/api/utils/__init__.py
Normal file
106
system3-nonconformance/api/utils/tkuser_client.py
Normal file
106
system3-nonconformance/api/utils/tkuser_client.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import httpx
|
||||
import os
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
TKUSER_API_URL = os.getenv("TKUSER_API_URL", "http://tkuser-api:3000")
|
||||
|
||||
|
||||
def get_token_from_request(request: Request) -> str:
|
||||
"""Request 헤더에서 Bearer 토큰 추출"""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
return auth[7:]
|
||||
return request.cookies.get("sso_token", "")
|
||||
|
||||
|
||||
def _headers(token: str) -> dict:
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="인증 토큰이 필요합니다.")
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _map_project(data: dict) -> dict:
|
||||
"""tkuser API 응답을 S3 프론트엔드 형식으로 매핑 (project_id → id)"""
|
||||
return {
|
||||
"id": data.get("project_id"),
|
||||
"job_no": data.get("job_no"),
|
||||
"project_name": data.get("project_name"),
|
||||
"is_active": data.get("is_active", True),
|
||||
"created_at": data.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
async def get_projects(token: str, active_only: bool = True) -> list:
|
||||
"""프로젝트 목록 조회"""
|
||||
endpoint = "/api/projects/active" if active_only else "/api/projects"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(f"{TKUSER_API_URL}{endpoint}", headers=_headers(token))
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=resp.status_code, detail="프로젝트 목록 조회 실패")
|
||||
body = resp.json()
|
||||
if not body.get("success"):
|
||||
raise HTTPException(status_code=500, detail=body.get("error", "알 수 없는 오류"))
|
||||
return [_map_project(p) for p in body.get("data", [])]
|
||||
|
||||
|
||||
async def get_project(token: str, project_id: int) -> dict | None:
|
||||
"""특정 프로젝트 조회. 없으면 None 반환"""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"{TKUSER_API_URL}/api/projects/{project_id}", headers=_headers(token)
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=resp.status_code, detail="프로젝트 조회 실패")
|
||||
body = resp.json()
|
||||
if not body.get("success"):
|
||||
raise HTTPException(status_code=500, detail=body.get("error", "알 수 없는 오류"))
|
||||
return _map_project(body.get("data"))
|
||||
|
||||
|
||||
async def create_project(token: str, data: dict) -> dict:
|
||||
"""프로젝트 생성"""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{TKUSER_API_URL}/api/projects", headers=_headers(token), json=data
|
||||
)
|
||||
if resp.status_code == 409:
|
||||
raise HTTPException(status_code=400, detail="이미 존재하는 Job No.입니다.")
|
||||
if resp.status_code not in (200, 201):
|
||||
raise HTTPException(status_code=resp.status_code, detail="프로젝트 생성 실패")
|
||||
body = resp.json()
|
||||
if not body.get("success"):
|
||||
raise HTTPException(status_code=500, detail=body.get("error", "알 수 없는 오류"))
|
||||
return _map_project(body.get("data"))
|
||||
|
||||
|
||||
async def update_project(token: str, project_id: int, data: dict) -> dict:
|
||||
"""프로젝트 수정"""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.put(
|
||||
f"{TKUSER_API_URL}/api/projects/{project_id}",
|
||||
headers=_headers(token),
|
||||
json=data,
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=resp.status_code, detail="프로젝트 수정 실패")
|
||||
body = resp.json()
|
||||
if not body.get("success"):
|
||||
raise HTTPException(status_code=500, detail=body.get("error", "알 수 없는 오류"))
|
||||
return _map_project(body.get("data"))
|
||||
|
||||
|
||||
async def delete_project(token: str, project_id: int) -> dict:
|
||||
"""프로젝트 삭제 (비활성화)"""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.delete(
|
||||
f"{TKUSER_API_URL}/api/projects/{project_id}", headers=_headers(token)
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=resp.status_code, detail="프로젝트 삭제 실패")
|
||||
return resp.json()
|
||||
Reference in New Issue
Block a user