feat: 사진 업로드 기능 개선 및 카테고리 업데이트

- 사진 2장까지 업로드 지원
- 카메라 촬영 + 갤러리 선택 분리
- 이미지 압축 및 최적화 (ImageUtils)
- iPhone .mpo 파일 JPEG 변환 지원
- 카테고리 변경: 치수불량 → 설계미스, 검사미스 추가
- KST 시간대 설정
- URL 해시 처리로 목록관리 페이지 이동 개선
- 로그인 OAuth2 form-data 형식 수정
- 업로드 속도 개선 및 프로그레스바 추가
This commit is contained in:
hyungi
2025-09-18 07:00:28 +09:00
parent f6bdb68d19
commit 44e2fb2e44
32 changed files with 988 additions and 177 deletions

View File

@@ -1,24 +1,32 @@
from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, Text, ForeignKey, Enum
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime
from datetime import datetime, timezone, timedelta
import enum
# 한국 시간대 설정
KST = timezone(timedelta(hours=9))
def get_kst_now():
"""현재 한국 시간 반환"""
return datetime.now(KST)
Base = declarative_base()
class UserRole(str, enum.Enum):
ADMIN = "admin"
USER = "user"
admin = "admin"
user = "user"
class IssueStatus(str, enum.Enum):
NEW = "new"
PROGRESS = "progress"
COMPLETE = "complete"
new = "new"
progress = "progress"
complete = "complete"
class IssueCategory(str, enum.Enum):
MATERIAL_MISSING = "material_missing"
DIMENSION_DEFECT = "dimension_defect"
INCOMING_DEFECT = "incoming_defect"
material_missing = "material_missing"
design_error = "design_error" # 설계미스 (기존 dimension_defect 대체)
incoming_defect = "incoming_defect"
inspection_miss = "inspection_miss" # 검사미스 (신규 추가)
class User(Base):
__tablename__ = "users"
@@ -27,9 +35,9 @@ class User(Base):
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String)
role = Column(Enum(UserRole), default=UserRole.USER)
role = Column(Enum(UserRole), default=UserRole.user)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=get_kst_now)
# Relationships
issues = relationship("Issue", back_populates="reporter")
@@ -40,11 +48,12 @@ class Issue(Base):
id = Column(Integer, primary_key=True, index=True)
photo_path = Column(String)
photo_path2 = Column(String) # 두 번째 사진 경로
category = Column(Enum(IssueCategory), nullable=False)
description = Column(Text, nullable=False)
status = Column(Enum(IssueStatus), default=IssueStatus.NEW)
status = Column(Enum(IssueStatus), default=IssueStatus.new)
reporter_id = Column(Integer, ForeignKey("users.id"))
report_date = Column(DateTime, default=datetime.utcnow)
report_date = Column(DateTime, default=get_kst_now)
work_hours = Column(Float, default=0)
detail_notes = Column(Text)
@@ -63,7 +72,7 @@ class DailyWork(Base):
overtime_total = Column(Float, default=0)
total_hours = Column(Float, nullable=False)
created_by_id = Column(Integer, ForeignKey("users.id"))
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=get_kst_now)
# Relationships
created_by = relationship("User", back_populates="daily_works")

View File

@@ -14,8 +14,9 @@ class IssueStatus(str, Enum):
class IssueCategory(str, Enum):
material_missing = "material_missing"
dimension_defect = "dimension_defect"
design_error = "design_error" # 설계미스 (기존 dimension_defect 대체)
incoming_defect = "incoming_defect"
inspection_miss = "inspection_miss" # 검사미스 (신규 추가)
# User schemas
class UserBase(BaseModel):
@@ -64,6 +65,7 @@ class IssueBase(BaseModel):
class IssueCreate(IssueBase):
photo: Optional[str] = None # Base64 encoded image
photo2: Optional[str] = None # Second Base64 encoded image
class IssueUpdate(BaseModel):
category: Optional[IssueCategory] = None
@@ -72,10 +74,12 @@ class IssueUpdate(BaseModel):
detail_notes: Optional[str] = None
status: Optional[IssueStatus] = None
photo: Optional[str] = None # Base64 encoded image for update
photo2: Optional[str] = None # Second Base64 encoded image for update
class Issue(IssueBase):
id: int
photo_path: Optional[str] = None
photo_path2: Optional[str] = None # 두 번째 사진 경로
status: IssueStatus
reporter_id: int
reporter: User

View File

@@ -9,6 +9,8 @@ from routers import auth, issues, daily_work, reports
from services.auth_service import create_admin_user
# 데이터베이스 테이블 생성
# 메타데이터 캐시 클리어
Base.metadata.clear()
Base.metadata.create_all(bind=engine)
# FastAPI 앱 생성

View File

@@ -1,10 +1,22 @@
-- 초기 데이터베이스 설정
-- Enum 타입 생성 (4개 카테고리)
CREATE TYPE userRole AS ENUM ('admin', 'user');
CREATE TYPE issueStatus AS ENUM ('new', 'progress', 'complete');
CREATE TYPE issueCategory AS ENUM (
'material_missing', -- 자재누락
'design_error', -- 설계미스
'incoming_defect', -- 입고자재 불량
'inspection_miss' -- 검사미스
);
-- 사용자 테이블
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
hashed_password VARCHAR(255) NOT NULL,
full_name VARCHAR(100),
role VARCHAR(20) DEFAULT 'user',
role userRole DEFAULT 'user',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@@ -12,20 +24,21 @@ CREATE TABLE IF NOT EXISTS users (
-- 이슈 테이블
CREATE TABLE IF NOT EXISTS issues (
id SERIAL PRIMARY KEY,
photo_path VARCHAR(255),
category VARCHAR(50) NOT NULL,
photo_path VARCHAR(500),
photo_path2 VARCHAR(500), -- 두 번째 사진
category issueCategory NOT NULL,
description TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'new',
status issueStatus DEFAULT 'new',
reporter_id INTEGER REFERENCES users(id),
report_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
work_hours FLOAT DEFAULT 0,
detail_notes TEXT
);
-- 일일 공수 테이블
-- 일일 작업 테이블
CREATE TABLE IF NOT EXISTS daily_works (
id SERIAL PRIMARY KEY,
date DATE NOT NULL UNIQUE,
date DATE NOT NULL,
worker_count INTEGER NOT NULL,
regular_hours FLOAT NOT NULL,
overtime_workers INTEGER DEFAULT 0,
@@ -33,11 +46,13 @@ CREATE TABLE IF NOT EXISTS daily_works (
overtime_total FLOAT DEFAULT 0,
total_hours FLOAT NOT NULL,
created_by_id INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date)
);
-- 인덱스 생성
CREATE INDEX idx_issues_reporter_id ON issues(reporter_id);
CREATE INDEX idx_issues_status ON issues(status);
CREATE INDEX idx_issues_report_date ON issues(report_date);
CREATE INDEX idx_issues_category ON issues(category);
CREATE INDEX idx_daily_works_date ON daily_works(date);
CREATE INDEX idx_daily_works_created_by_id ON daily_works(created_by_id);

View File

@@ -0,0 +1,5 @@
-- 두 번째 사진 경로 추가
ALTER TABLE issues ADD COLUMN IF NOT EXISTS photo_path2 VARCHAR(500);
-- 인덱스 추가 (선택사항)
CREATE INDEX IF NOT EXISTS idx_issues_photo_path2 ON issues(photo_path2);

View File

@@ -0,0 +1,8 @@
-- 카테고리 업데이트 마이그레이션
-- dimension_defect를 design_error로 변경
UPDATE issues
SET category = 'design_error'
WHERE category = 'dimension_defect';
-- PostgreSQL enum 타입 업데이트 (필요한 경우)
-- 기존 enum 타입 확인 후 필요시 재생성

View File

@@ -0,0 +1,9 @@
-- 카테고리 값 정규화 (대문자를 소문자로 변경)
-- 기존 DIMENSION_DEFECT를 design_error로 변경
UPDATE issues SET category = 'design_error' WHERE category IN ('DIMENSION_DEFECT', 'dimension_defect');
UPDATE issues SET category = 'material_missing' WHERE category = 'MATERIAL_MISSING';
UPDATE issues SET category = 'incoming_defect' WHERE category = 'INCOMING_DEFECT';
UPDATE issues SET category = 'inspection_miss' WHERE category = 'INSPECTION_MISS';
-- 카테고리 값 확인
SELECT category, COUNT(*) FROM issues GROUP BY category;

View File

@@ -0,0 +1,20 @@
-- PostgreSQL enum 타입 재생성
-- 카테고리 컬럼을 임시로 텍스트로 변경
ALTER TABLE issues ALTER COLUMN category TYPE VARCHAR(50);
-- 기존 enum 타입 삭제
DROP TYPE IF EXISTS issuecategory CASCADE;
-- 새로운 enum 타입 생성 (4개 카테고리)
CREATE TYPE issuecategory AS ENUM (
'material_missing', -- 자재누락
'design_error', -- 설계미스 (기존 dimension_defect 대체)
'incoming_defect', -- 입고자재 불량
'inspection_miss' -- 검사미스 (신규)
);
-- 카테고리 컬럼을 새 enum 타입으로 변경
ALTER TABLE issues ALTER COLUMN category TYPE issuecategory USING category::issuecategory;
-- 확인
SELECT enumlabel FROM pg_enum WHERE enumtypid = (SELECT oid FROM pg_type WHERE typname = 'issuecategory');

View File

@@ -29,7 +29,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
return user
async def get_current_admin(current_user: User = Depends(get_current_user)):
if current_user.role != UserRole.ADMIN:
if current_user.role != UserRole.admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
@@ -37,8 +37,8 @@ async def get_current_admin(current_user: User = Depends(get_current_user)):
return current_user
@router.post("/login", response_model=schemas.Token)
async def login(login_data: schemas.LoginRequest, db: Session = Depends(get_db)):
user = authenticate_user(db, login_data.username, login_data.password)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,

View File

@@ -1,10 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime, date
from datetime import datetime, date, timezone, timedelta
from database.database import get_db
from database.models import DailyWork, User, UserRole
from database.models import DailyWork, User, UserRole, KST
from database import schemas
from routers.auth import get_current_user
@@ -120,7 +120,7 @@ async def delete_daily_work(
raise HTTPException(status_code=404, detail="Daily work not found")
# 권한 확인 (관리자만 삭제 가능)
if current_user.role != UserRole.ADMIN:
if current_user.role != UserRole.admin:
raise HTTPException(status_code=403, detail="Only admin can delete daily work")
db.delete(work)

View File

@@ -19,16 +19,22 @@ async def create_issue(
):
# 이미지 저장
photo_path = None
photo_path2 = None
if issue.photo:
photo_path = save_base64_image(issue.photo)
if issue.photo2:
photo_path2 = save_base64_image(issue.photo2)
# Issue 생성
db_issue = Issue(
category=issue.category,
description=issue.description,
photo_path=photo_path,
photo_path2=photo_path2,
reporter_id=current_user.id,
status=IssueStatus.NEW
status=IssueStatus.new
)
db.add(db_issue)
db.commit()
@@ -45,9 +51,8 @@ async def read_issues(
):
query = db.query(Issue)
# 일반 사용자는 자신의 이슈 조회
if current_user.role == UserRole.USER:
query = query.filter(Issue.reporter_id == current_user.id)
# 모든 사용자가 모든 이슈 조회 가능
# (필터링 제거 - 협업을 위해 모두가 볼 수 있어야 함)
if status:
query = query.filter(Issue.status == status)
@@ -65,9 +70,7 @@ async def read_issue(
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
# 권한 확인
if current_user.role == UserRole.USER and issue.reporter_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to view this issue")
# 모든 사용자가 모든 이슈를 조회 가능 (협업을 위해)
return issue
@@ -83,13 +86,13 @@ async def update_issue(
raise HTTPException(status_code=404, detail="Issue not found")
# 권한 확인
if current_user.role == UserRole.USER and issue.reporter_id != current_user.id:
if current_user.role == UserRole.user and issue.reporter_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to update this issue")
# 업데이트
update_data = issue_update.dict(exclude_unset=True)
# 사진이 업데이트되는 경우 처리
# 첫 번째 사진이 업데이트되는 경우 처리
if "photo" in update_data:
# 기존 사진 삭제
if issue.photo_path:
@@ -105,10 +108,26 @@ async def update_issue(
# photo 필드는 제거 (DB에는 photo_path만 저장)
del update_data["photo"]
# 두 번째 사진이 업데이트되는 경우 처리
if "photo2" in update_data:
# 기존 사진 삭제
if issue.photo_path2:
delete_file(issue.photo_path2)
# 새 사진 저장
if update_data["photo2"]:
photo_path2 = save_base64_image(update_data["photo2"])
update_data["photo_path2"] = photo_path2
else:
update_data["photo_path2"] = None
# photo2 필드는 제거 (DB에는 photo_path2만 저장)
del update_data["photo2"]
# work_hours가 입력되면 자동으로 상태를 complete로 변경
if "work_hours" in update_data and update_data["work_hours"] > 0:
if issue.status == IssueStatus.NEW:
update_data["status"] = IssueStatus.COMPLETE
if issue.status == IssueStatus.new:
update_data["status"] = IssueStatus.complete
for field, value in update_data.items():
setattr(issue, field, value)
@@ -128,7 +147,7 @@ async def delete_issue(
raise HTTPException(status_code=404, detail="Issue not found")
# 권한 확인 (관리자만 삭제 가능)
if current_user.role != UserRole.ADMIN:
if current_user.role != UserRole.admin:
raise HTTPException(status_code=403, detail="Only admin can delete issues")
# 이미지 파일 삭제
@@ -148,7 +167,7 @@ async def get_issue_stats(
query = db.query(Issue)
# 일반 사용자는 자신의 이슈만
if current_user.role == UserRole.USER:
if current_user.role == UserRole.user:
query = query.filter(Issue.reporter_id == current_user.id)
total = query.count()

View File

@@ -35,7 +35,7 @@ async def generate_report_summary(
)
# 일반 사용자는 자신의 이슈만
if current_user.role == UserRole.USER:
if current_user.role == UserRole.user:
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
issues = issues_query.all()
@@ -89,7 +89,7 @@ async def get_report_issues(
)
# 일반 사용자는 자신의 이슈만
if current_user.role == UserRole.USER:
if current_user.role == UserRole.user:
query = query.filter(Issue.reporter_id == current_user.id)
issues = query.order_by(Issue.report_date).all()

View File

@@ -63,7 +63,7 @@ def create_admin_user(db: Session):
username=admin_username,
hashed_password=get_password_hash(admin_password),
full_name="관리자",
role=UserRole.ADMIN,
role=UserRole.admin,
is_active=True
)
db.add(admin_user)

View File

@@ -27,20 +27,29 @@ def save_base64_image(base64_string: str) -> Optional[str]:
# 이미지 검증 및 형식 확인
image = Image.open(io.BytesIO(image_data))
format = image.format.lower() if image.format else 'png'
# 파일명 생성
filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}.{format}"
# iPhone의 .mpo 파일이나 기타 형식을 JPEG로 강제 변환
# RGB 모드로 변환 (RGBA, P 모드 등을 처리)
if image.mode in ('RGBA', 'LA', 'P'):
# 투명도가 있는 이미지는 흰 배경과 합성
background = Image.new('RGB', image.size, (255, 255, 255))
if image.mode == 'P':
image = image.convert('RGBA')
background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
image = background
elif image.mode != 'RGB':
image = image.convert('RGB')
# 파일명 생성 (강제로 .jpg)
filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}.jpg"
filepath = os.path.join(UPLOAD_DIR, filename)
# 이미지 저장 (최대 크기 제한)
max_size = (1920, 1920)
image.thumbnail(max_size, Image.Resampling.LANCZOS)
if format == 'png':
image.save(filepath, 'PNG', optimize=True)
else:
image.save(filepath, 'JPEG', quality=85, optimize=True)
# 항상 JPEG로 저장
image.save(filepath, 'JPEG', quality=85, optimize=True)
# 웹 경로 반환
return f"/uploads/{filename}"