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:
Hyungi Ahn
2026-02-12 13:45:52 +09:00
parent 6495b8af32
commit 733bb0cb35
96 changed files with 9721 additions and 825 deletions

View File

@@ -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()

View File

@@ -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) # 삭제 사유 (선택사항)

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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"

View File

@@ -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:

View File

@@ -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,

View File

@@ -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": "프로젝트가 삭제되었습니다."}

View File

@@ -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

View File

@@ -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}")

View 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()