feat: 작업보고서 시스템 완성
- 일일 공수 입력 기능 - 부적합 사항 등록 (이미지 선택사항) - 날짜별 부적합 조회 (시간순 나열) - 목록 관리 (인라인 편집, 작업시간 확인 버튼) - 보고서 생성 (총 공수/부적합 시간 분리) - JWT 인증 및 권한 관리 - Docker 기반 배포 환경 구성
This commit is contained in:
24
backend/Dockerfile
Normal file
24
backend/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 시스템 패키지 설치
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python 의존성 설치
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 애플리케이션 파일 복사
|
||||
COPY . .
|
||||
|
||||
# uploads 디렉토리 생성
|
||||
RUN mkdir -p /app/uploads
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 8000
|
||||
|
||||
# 실행 명령
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
BIN
backend/__pycache__/main.cpython-311.pyc
Normal file
BIN
backend/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/database/__pycache__/database.cpython-311.pyc
Normal file
BIN
backend/database/__pycache__/database.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/database/__pycache__/models.cpython-311.pyc
Normal file
BIN
backend/database/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/database/__pycache__/schemas.cpython-311.pyc
Normal file
BIN
backend/database/__pycache__/schemas.cpython-311.pyc
Normal file
Binary file not shown.
19
backend/database/database.py
Normal file
19
backend/database/database.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
import os
|
||||
from typing import Generator
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://mproject:mproject2024@localhost:5432/mproject")
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
69
backend/database/models.py
Normal file
69
backend/database/models.py
Normal file
@@ -0,0 +1,69 @@
|
||||
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
|
||||
import enum
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
|
||||
class IssueStatus(str, enum.Enum):
|
||||
NEW = "new"
|
||||
PROGRESS = "progress"
|
||||
COMPLETE = "complete"
|
||||
|
||||
class IssueCategory(str, enum.Enum):
|
||||
MATERIAL_MISSING = "material_missing"
|
||||
DIMENSION_DEFECT = "dimension_defect"
|
||||
INCOMING_DEFECT = "incoming_defect"
|
||||
|
||||
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)
|
||||
role = Column(Enum(UserRole), default=UserRole.USER)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
issues = relationship("Issue", back_populates="reporter")
|
||||
daily_works = relationship("DailyWork", back_populates="created_by")
|
||||
|
||||
class Issue(Base):
|
||||
__tablename__ = "issues"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
photo_path = Column(String)
|
||||
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"))
|
||||
report_date = Column(DateTime, default=datetime.utcnow)
|
||||
work_hours = Column(Float, default=0)
|
||||
detail_notes = Column(Text)
|
||||
|
||||
# Relationships
|
||||
reporter = relationship("User", back_populates="issues")
|
||||
|
||||
class DailyWork(Base):
|
||||
__tablename__ = "daily_works"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
date = Column(DateTime, nullable=False, index=True)
|
||||
worker_count = Column(Integer, nullable=False)
|
||||
regular_hours = Column(Float, nullable=False)
|
||||
overtime_workers = Column(Integer, default=0)
|
||||
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_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
created_by = relationship("User", back_populates="daily_works")
|
||||
129
backend/database/schemas.py
Normal file
129
backend/database/schemas.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from enum import Enum
|
||||
|
||||
class UserRole(str, Enum):
|
||||
admin = "admin"
|
||||
user = "user"
|
||||
|
||||
class IssueStatus(str, Enum):
|
||||
new = "new"
|
||||
progress = "progress"
|
||||
complete = "complete"
|
||||
|
||||
class IssueCategory(str, Enum):
|
||||
material_missing = "material_missing"
|
||||
dimension_defect = "dimension_defect"
|
||||
incoming_defect = "incoming_defect"
|
||||
|
||||
# User schemas
|
||||
class UserBase(BaseModel):
|
||||
username: str
|
||||
full_name: Optional[str] = None
|
||||
role: UserRole = UserRole.user
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
role: Optional[UserRole] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class User(UserBase):
|
||||
id: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Auth schemas
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
user: User
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
# Issue schemas
|
||||
class IssueBase(BaseModel):
|
||||
category: IssueCategory
|
||||
description: str
|
||||
|
||||
class IssueCreate(IssueBase):
|
||||
photo: Optional[str] = None # Base64 encoded image
|
||||
|
||||
class IssueUpdate(BaseModel):
|
||||
category: Optional[IssueCategory] = None
|
||||
description: Optional[str] = None
|
||||
work_hours: Optional[float] = None
|
||||
detail_notes: Optional[str] = None
|
||||
status: Optional[IssueStatus] = None
|
||||
photo: Optional[str] = None # Base64 encoded image for update
|
||||
|
||||
class Issue(IssueBase):
|
||||
id: int
|
||||
photo_path: Optional[str] = None
|
||||
status: IssueStatus
|
||||
reporter_id: int
|
||||
reporter: User
|
||||
report_date: datetime
|
||||
work_hours: float
|
||||
detail_notes: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Daily Work schemas
|
||||
class DailyWorkBase(BaseModel):
|
||||
date: datetime
|
||||
worker_count: int = Field(gt=0)
|
||||
overtime_workers: Optional[int] = 0
|
||||
overtime_hours: Optional[float] = 0
|
||||
|
||||
class DailyWorkCreate(DailyWorkBase):
|
||||
pass
|
||||
|
||||
class DailyWorkUpdate(BaseModel):
|
||||
worker_count: Optional[int] = Field(None, gt=0)
|
||||
overtime_workers: Optional[int] = None
|
||||
overtime_hours: Optional[float] = None
|
||||
|
||||
class DailyWork(DailyWorkBase):
|
||||
id: int
|
||||
regular_hours: float
|
||||
overtime_total: float
|
||||
total_hours: float
|
||||
created_by_id: int
|
||||
created_by: User
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Report schemas
|
||||
class ReportRequest(BaseModel):
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
|
||||
class CategoryStats(BaseModel):
|
||||
material_missing: int = 0
|
||||
dimension_defect: int = 0
|
||||
incoming_defect: int = 0
|
||||
|
||||
class ReportSummary(BaseModel):
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
total_hours: float
|
||||
total_issues: int
|
||||
category_stats: CategoryStats
|
||||
completed_issues: int
|
||||
average_resolution_time: float
|
||||
62
backend/main.py
Normal file
62
backend/main.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
import uvicorn
|
||||
|
||||
from database.database import engine, get_db
|
||||
from database.models import Base
|
||||
from routers import auth, issues, daily_work, reports
|
||||
from services.auth_service import create_admin_user
|
||||
|
||||
# 데이터베이스 테이블 생성
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# FastAPI 앱 생성
|
||||
app = FastAPI(
|
||||
title="M-Project API",
|
||||
description="작업보고서 시스템 API",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# CORS 설정
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 프로덕션에서는 구체적인 도메인으로 변경
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 라우터 등록
|
||||
app.include_router(auth.router)
|
||||
app.include_router(issues.router)
|
||||
app.include_router(daily_work.router)
|
||||
app.include_router(reports.router)
|
||||
|
||||
# 시작 시 관리자 계정 생성
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
db = next(get_db())
|
||||
create_admin_user(db)
|
||||
db.close()
|
||||
|
||||
# 루트 엔드포인트
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "M-Project API", "version": "1.0.0"}
|
||||
|
||||
# 헬스체크
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy"}
|
||||
|
||||
# 전역 예외 처리
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": f"Internal server error: {str(exc)}"}
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
43
backend/migrations/001_init.sql
Normal file
43
backend/migrations/001_init.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- 사용자 테이블
|
||||
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',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 이슈 테이블
|
||||
CREATE TABLE IF NOT EXISTS issues (
|
||||
id SERIAL PRIMARY KEY,
|
||||
photo_path VARCHAR(255),
|
||||
category VARCHAR(50) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status VARCHAR(20) 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,
|
||||
worker_count INTEGER NOT NULL,
|
||||
regular_hours FLOAT NOT NULL,
|
||||
overtime_workers INTEGER DEFAULT 0,
|
||||
overtime_hours FLOAT DEFAULT 0,
|
||||
overtime_total FLOAT DEFAULT 0,
|
||||
total_hours FLOAT NOT NULL,
|
||||
created_by_id INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
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_daily_works_date ON daily_works(date);
|
||||
12
backend/requirements.txt
Normal file
12
backend/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
python-multipart==0.0.6
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
sqlalchemy==2.0.23
|
||||
psycopg2-binary==2.9.9
|
||||
alembic==1.12.1
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
pillow==10.1.0
|
||||
reportlab==4.0.7
|
||||
BIN
backend/routers/__pycache__/auth.cpython-311.pyc
Normal file
BIN
backend/routers/__pycache__/auth.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/daily_work.cpython-311.pyc
Normal file
BIN
backend/routers/__pycache__/daily_work.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/issues.cpython-311.pyc
Normal file
BIN
backend/routers/__pycache__/issues.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/routers/__pycache__/reports.cpython-311.pyc
Normal file
BIN
backend/routers/__pycache__/reports.cpython-311.pyc
Normal file
Binary file not shown.
131
backend/routers/auth.py
Normal file
131
backend/routers/auth.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import User, UserRole
|
||||
from database import schemas
|
||||
from services.auth_service import (
|
||||
authenticate_user, create_access_token, verify_token,
|
||||
get_password_hash, verify_password
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
token_data = verify_token(token, credentials_exception)
|
||||
user = db.query(User).filter(User.username == token_data.username).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return user
|
||||
|
||||
async def get_current_admin(current_user: User = Depends(get_current_user)):
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
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)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
access_token = create_access_token(data={"sub": user.username})
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": user
|
||||
}
|
||||
|
||||
@router.get("/me", response_model=schemas.User)
|
||||
async def read_users_me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
@router.post("/users", response_model=schemas.User)
|
||||
async def create_user(
|
||||
user: schemas.UserCreate,
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# 중복 확인
|
||||
db_user = db.query(User).filter(User.username == user.username).first()
|
||||
if db_user:
|
||||
raise HTTPException(status_code=400, detail="Username already registered")
|
||||
|
||||
# 사용자 생성
|
||||
db_user = User(
|
||||
username=user.username,
|
||||
hashed_password=get_password_hash(user.password),
|
||||
full_name=user.full_name,
|
||||
role=user.role
|
||||
)
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
@router.get("/users", response_model=List[schemas.User])
|
||||
async def read_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
users = db.query(User).offset(skip).limit(limit).all()
|
||||
return users
|
||||
|
||||
@router.put("/users/{user_id}", response_model=schemas.User)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
user_update: schemas.UserUpdate,
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
db_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# 업데이트
|
||||
update_data = user_update.dict(exclude_unset=True)
|
||||
if "password" in update_data:
|
||||
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(db_user, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
current_admin: User = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
db_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# 관리자는 삭제 불가
|
||||
if db_user.role == UserRole.ADMIN:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete admin user")
|
||||
|
||||
db.delete(db_user)
|
||||
db.commit()
|
||||
return {"detail": "User deleted successfully"}
|
||||
163
backend/routers/daily_work.py
Normal file
163
backend/routers/daily_work.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, date
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import DailyWork, User, UserRole
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/daily-work", tags=["daily-work"])
|
||||
|
||||
@router.post("/", response_model=schemas.DailyWork)
|
||||
async def create_daily_work(
|
||||
work: schemas.DailyWorkCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# 중복 확인 (같은 날짜)
|
||||
existing = db.query(DailyWork).filter(
|
||||
DailyWork.date == work.date.date()
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Daily work for this date already exists")
|
||||
|
||||
# 계산
|
||||
regular_hours = work.worker_count * 8 # 정규 근무 8시간
|
||||
overtime_total = work.overtime_workers * work.overtime_hours
|
||||
total_hours = regular_hours + overtime_total
|
||||
|
||||
# 생성
|
||||
db_work = DailyWork(
|
||||
date=work.date,
|
||||
worker_count=work.worker_count,
|
||||
regular_hours=regular_hours,
|
||||
overtime_workers=work.overtime_workers,
|
||||
overtime_hours=work.overtime_hours,
|
||||
overtime_total=overtime_total,
|
||||
total_hours=total_hours,
|
||||
created_by_id=current_user.id
|
||||
)
|
||||
db.add(db_work)
|
||||
db.commit()
|
||||
db.refresh(db_work)
|
||||
return db_work
|
||||
|
||||
@router.get("/", response_model=List[schemas.DailyWork])
|
||||
async def read_daily_works(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
query = db.query(DailyWork)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(DailyWork.date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(DailyWork.date <= end_date)
|
||||
|
||||
works = query.order_by(DailyWork.date.desc()).offset(skip).limit(limit).all()
|
||||
return works
|
||||
|
||||
@router.get("/{work_id}", response_model=schemas.DailyWork)
|
||||
async def read_daily_work(
|
||||
work_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
|
||||
if not work:
|
||||
raise HTTPException(status_code=404, detail="Daily work not found")
|
||||
return work
|
||||
|
||||
@router.put("/{work_id}", response_model=schemas.DailyWork)
|
||||
async def update_daily_work(
|
||||
work_id: int,
|
||||
work_update: schemas.DailyWorkUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
|
||||
if not work:
|
||||
raise HTTPException(status_code=404, detail="Daily work not found")
|
||||
|
||||
# 업데이트
|
||||
update_data = work_update.dict(exclude_unset=True)
|
||||
|
||||
# 재계산 필요한 경우
|
||||
if any(key in update_data for key in ["worker_count", "overtime_workers", "overtime_hours"]):
|
||||
worker_count = update_data.get("worker_count", work.worker_count)
|
||||
overtime_workers = update_data.get("overtime_workers", work.overtime_workers)
|
||||
overtime_hours = update_data.get("overtime_hours", work.overtime_hours)
|
||||
|
||||
regular_hours = worker_count * 8
|
||||
overtime_total = overtime_workers * overtime_hours
|
||||
total_hours = regular_hours + overtime_total
|
||||
|
||||
update_data["regular_hours"] = regular_hours
|
||||
update_data["overtime_total"] = overtime_total
|
||||
update_data["total_hours"] = total_hours
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(work, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(work)
|
||||
return work
|
||||
|
||||
@router.delete("/{work_id}")
|
||||
async def delete_daily_work(
|
||||
work_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
|
||||
if not work:
|
||||
raise HTTPException(status_code=404, detail="Daily work not found")
|
||||
|
||||
# 권한 확인 (관리자만 삭제 가능)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Only admin can delete daily work")
|
||||
|
||||
db.delete(work)
|
||||
db.commit()
|
||||
return {"detail": "Daily work deleted successfully"}
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def get_daily_work_stats(
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""일일 공수 통계"""
|
||||
query = db.query(DailyWork)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(DailyWork.date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(DailyWork.date <= end_date)
|
||||
|
||||
works = query.all()
|
||||
|
||||
if not works:
|
||||
return {
|
||||
"total_days": 0,
|
||||
"total_hours": 0,
|
||||
"total_overtime": 0,
|
||||
"average_daily_hours": 0
|
||||
}
|
||||
|
||||
total_hours = sum(w.total_hours for w in works)
|
||||
total_overtime = sum(w.overtime_total for w in works)
|
||||
|
||||
return {
|
||||
"total_days": len(works),
|
||||
"total_hours": total_hours,
|
||||
"total_overtime": total_overtime,
|
||||
"average_daily_hours": total_hours / len(works)
|
||||
}
|
||||
164
backend/routers/issues.py
Normal file
164
backend/routers/issues.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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, IssueStatus, User, UserRole
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
from services.file_service import save_base64_image, delete_file
|
||||
|
||||
router = APIRouter(prefix="/api/issues", tags=["issues"])
|
||||
|
||||
@router.post("/", response_model=schemas.Issue)
|
||||
async def create_issue(
|
||||
issue: schemas.IssueCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# 이미지 저장
|
||||
photo_path = None
|
||||
if issue.photo:
|
||||
photo_path = save_base64_image(issue.photo)
|
||||
|
||||
# Issue 생성
|
||||
db_issue = Issue(
|
||||
category=issue.category,
|
||||
description=issue.description,
|
||||
photo_path=photo_path,
|
||||
reporter_id=current_user.id,
|
||||
status=IssueStatus.NEW
|
||||
)
|
||||
db.add(db_issue)
|
||||
db.commit()
|
||||
db.refresh(db_issue)
|
||||
return db_issue
|
||||
|
||||
@router.get("/", response_model=List[schemas.Issue])
|
||||
async def read_issues(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
status: Optional[IssueStatus] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
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)
|
||||
|
||||
issues = query.offset(skip).limit(limit).all()
|
||||
return issues
|
||||
|
||||
@router.get("/{issue_id}", response_model=schemas.Issue)
|
||||
async def read_issue(
|
||||
issue_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
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
|
||||
|
||||
@router.put("/{issue_id}", response_model=schemas.Issue)
|
||||
async def update_issue(
|
||||
issue_id: int,
|
||||
issue_update: schemas.IssueUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
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 update this issue")
|
||||
|
||||
# 업데이트
|
||||
update_data = issue_update.dict(exclude_unset=True)
|
||||
|
||||
# 사진이 업데이트되는 경우 처리
|
||||
if "photo" in update_data:
|
||||
# 기존 사진 삭제
|
||||
if issue.photo_path:
|
||||
delete_file(issue.photo_path)
|
||||
|
||||
# 새 사진 저장
|
||||
if update_data["photo"]:
|
||||
photo_path = save_base64_image(update_data["photo"])
|
||||
update_data["photo_path"] = photo_path
|
||||
else:
|
||||
update_data["photo_path"] = None
|
||||
|
||||
# photo 필드는 제거 (DB에는 photo_path만 저장)
|
||||
del update_data["photo"]
|
||||
|
||||
# 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
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(issue, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
return issue
|
||||
|
||||
@router.delete("/{issue_id}")
|
||||
async def delete_issue(
|
||||
issue_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
issue = db.query(Issue).filter(Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
|
||||
# 권한 확인 (관리자만 삭제 가능)
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Only admin can delete issues")
|
||||
|
||||
# 이미지 파일 삭제
|
||||
if issue.photo_path:
|
||||
delete_file(issue.photo_path)
|
||||
|
||||
db.delete(issue)
|
||||
db.commit()
|
||||
return {"detail": "Issue deleted successfully"}
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def get_issue_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""이슈 통계 조회"""
|
||||
query = db.query(Issue)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.USER:
|
||||
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()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"new": new,
|
||||
"progress": progress,
|
||||
"complete": complete
|
||||
}
|
||||
130
backend/routers/reports.py
Normal file
130
backend/routers/reports.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from database.database import get_db
|
||||
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole
|
||||
from database import schemas
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@router.post("/summary", response_model=schemas.ReportSummary)
|
||||
async def generate_report_summary(
|
||||
report_request: schemas.ReportRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서 요약 생성"""
|
||||
start_date = report_request.start_date
|
||||
end_date = report_request.end_date
|
||||
|
||||
# 일일 공수 합계
|
||||
daily_works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).all()
|
||||
total_hours = sum(w.total_hours for w in daily_works)
|
||||
|
||||
# 이슈 통계
|
||||
issues_query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.USER:
|
||||
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = issues_query.all()
|
||||
|
||||
# 카테고리별 통계
|
||||
category_stats = schemas.CategoryStats()
|
||||
completed_issues = 0
|
||||
total_resolution_time = 0
|
||||
resolved_count = 0
|
||||
|
||||
for issue in issues:
|
||||
# 카테고리별 카운트
|
||||
if issue.category == IssueCategory.MATERIAL_MISSING:
|
||||
category_stats.material_missing += 1
|
||||
elif issue.category == IssueCategory.DIMENSION_DEFECT:
|
||||
category_stats.dimension_defect += 1
|
||||
elif issue.category == IssueCategory.INCOMING_DEFECT:
|
||||
category_stats.incoming_defect += 1
|
||||
|
||||
# 완료된 이슈
|
||||
if issue.status == IssueStatus.COMPLETE:
|
||||
completed_issues += 1
|
||||
if issue.work_hours > 0:
|
||||
total_resolution_time += issue.work_hours
|
||||
resolved_count += 1
|
||||
|
||||
# 평균 해결 시간
|
||||
average_resolution_time = total_resolution_time / resolved_count if resolved_count > 0 else 0
|
||||
|
||||
return schemas.ReportSummary(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
total_hours=total_hours,
|
||||
total_issues=len(issues),
|
||||
category_stats=category_stats,
|
||||
completed_issues=completed_issues,
|
||||
average_resolution_time=average_resolution_time
|
||||
)
|
||||
|
||||
@router.get("/issues")
|
||||
async def get_report_issues(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 이슈 상세 목록"""
|
||||
query = db.query(Issue).filter(
|
||||
Issue.report_date >= start_date,
|
||||
Issue.report_date <= end_date
|
||||
)
|
||||
|
||||
# 일반 사용자는 자신의 이슈만
|
||||
if current_user.role == UserRole.USER:
|
||||
query = query.filter(Issue.reporter_id == current_user.id)
|
||||
|
||||
issues = query.order_by(Issue.report_date).all()
|
||||
|
||||
return [{
|
||||
"id": issue.id,
|
||||
"photo_path": issue.photo_path,
|
||||
"category": issue.category,
|
||||
"description": issue.description,
|
||||
"status": issue.status,
|
||||
"reporter_name": issue.reporter.full_name or issue.reporter.username,
|
||||
"report_date": issue.report_date,
|
||||
"work_hours": issue.work_hours,
|
||||
"detail_notes": issue.detail_notes
|
||||
} for issue in issues]
|
||||
|
||||
@router.get("/daily-works")
|
||||
async def get_report_daily_works(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""보고서용 일일 공수 목록"""
|
||||
works = db.query(DailyWork).filter(
|
||||
DailyWork.date >= start_date.date(),
|
||||
DailyWork.date <= end_date.date()
|
||||
).order_by(DailyWork.date).all()
|
||||
|
||||
return [{
|
||||
"date": work.date,
|
||||
"worker_count": work.worker_count,
|
||||
"regular_hours": work.regular_hours,
|
||||
"overtime_workers": work.overtime_workers,
|
||||
"overtime_hours": work.overtime_hours,
|
||||
"overtime_total": work.overtime_total,
|
||||
"total_hours": work.total_hours
|
||||
} for work in works]
|
||||
BIN
backend/services/__pycache__/auth_service.cpython-311.pyc
Normal file
BIN
backend/services/__pycache__/auth_service.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/file_service.cpython-311.pyc
Normal file
BIN
backend/services/__pycache__/file_service.cpython-311.pyc
Normal file
Binary file not shown.
73
backend/services/auth_service.py
Normal file
73
backend/services/auth_service.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
import os
|
||||
|
||||
from database.models import User, UserRole
|
||||
from database.schemas import TokenData
|
||||
|
||||
# 환경 변수
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here")
|
||||
ALGORITHM = os.getenv("ALGORITHM", "HS256")
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7 days
|
||||
|
||||
# 비밀번호 암호화
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_token(token: str, credentials_exception):
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
token_data = TokenData(username=username)
|
||||
return token_data
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
def authenticate_user(db: Session, username: str, password: str):
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
return False
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return False
|
||||
return user
|
||||
|
||||
def create_admin_user(db: Session):
|
||||
"""초기 관리자 계정 생성"""
|
||||
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}")
|
||||
else:
|
||||
print(f"관리자 계정이 이미 존재함: {admin_username}")
|
||||
61
backend/services/file_service.py
Normal file
61
backend/services/file_service.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import uuid
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
UPLOAD_DIR = "/app/uploads"
|
||||
|
||||
def ensure_upload_dir():
|
||||
"""업로드 디렉토리 생성"""
|
||||
if not os.path.exists(UPLOAD_DIR):
|
||||
os.makedirs(UPLOAD_DIR)
|
||||
|
||||
def save_base64_image(base64_string: str) -> Optional[str]:
|
||||
"""Base64 이미지를 파일로 저장하고 경로 반환"""
|
||||
try:
|
||||
ensure_upload_dir()
|
||||
|
||||
# Base64 헤더 제거
|
||||
if "," in base64_string:
|
||||
base64_string = base64_string.split(",")[1]
|
||||
|
||||
# 디코딩
|
||||
image_data = base64.b64decode(base64_string)
|
||||
|
||||
# 이미지 검증 및 형식 확인
|
||||
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}"
|
||||
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)
|
||||
|
||||
# 웹 경로 반환
|
||||
return f"/uploads/{filename}"
|
||||
|
||||
except Exception as e:
|
||||
print(f"이미지 저장 실패: {e}")
|
||||
return None
|
||||
|
||||
def delete_file(filepath: str):
|
||||
"""파일 삭제"""
|
||||
try:
|
||||
if filepath and filepath.startswith("/uploads/"):
|
||||
filename = filepath.replace("/uploads/", "")
|
||||
full_path = os.path.join(UPLOAD_DIR, filename)
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
except Exception as e:
|
||||
print(f"파일 삭제 실패: {e}")
|
||||
Reference in New Issue
Block a user