feat: 사진 업로드 기능 개선 및 카테고리 업데이트
- 사진 2장까지 업로드 지원 - 카메라 촬영 + 갤러리 선택 분리 - 이미지 압축 및 최적화 (ImageUtils) - iPhone .mpo 파일 JPEG 변환 지원 - 카테고리 변경: 치수불량 → 설계미스, 검사미스 추가 - KST 시간대 설정 - URL 해시 처리로 목록관리 페이지 이동 개선 - 로그인 OAuth2 form-data 형식 수정 - 업로드 속도 개선 및 프로그레스바 추가
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 앱 생성
|
||||
|
||||
@@ -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);
|
||||
5
backend/migrations/002_add_second_photo.sql
Normal file
5
backend/migrations/002_add_second_photo.sql
Normal 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);
|
||||
8
backend/migrations/003_update_categories.sql
Normal file
8
backend/migrations/003_update_categories.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- 카테고리 업데이트 마이그레이션
|
||||
-- dimension_defect를 design_error로 변경
|
||||
UPDATE issues
|
||||
SET category = 'design_error'
|
||||
WHERE category = 'dimension_defect';
|
||||
|
||||
-- PostgreSQL enum 타입 업데이트 (필요한 경우)
|
||||
-- 기존 enum 타입 확인 후 필요시 재생성
|
||||
9
backend/migrations/004_fix_category_values.sql
Normal file
9
backend/migrations/004_fix_category_values.sql
Normal 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;
|
||||
20
backend/migrations/005_recreate_enum_type.sql
Normal file
20
backend/migrations/005_recreate_enum_type.sql
Normal 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');
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user