feat: Phase 0-2 complete — auth, machines, equipment parts with full CRUD

Multi-tenant factory inspection system (SpiFox, Enkid, Alpet):
- FastAPI backend with JWT auth, PostgreSQL (asyncpg)
- Next.js 16 frontend with App Router, SWR data fetching
- Machines CRUD with equipment parts management
- Part lifecycle tracking (hours/count/date) with counters
- Partial unique index for soft-delete support
- 24 pytest tests passing, E2E verified

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Johngreen
2026-02-10 12:05:22 +09:00
commit ab2a3e35b2
75 changed files with 13327 additions and 0 deletions

0
src/__init__.py Normal file
View File

0
src/api/__init__.py Normal file
View File

299
src/api/equipment_parts.py Normal file
View File

@@ -0,0 +1,299 @@
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends, Path
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.database.config import get_db
from src.database.models import Machine, EquipmentPart, PartCounter
from src.auth.models import TokenData
from src.auth.dependencies import require_auth, verify_tenant_access
router = APIRouter(tags=["equipment_parts"])
class PartCreate(BaseModel):
name: str
part_number: Optional[str] = None
category: Optional[str] = None
lifecycle_type: str # hours | count | date
lifecycle_limit: float
alarm_threshold: float = 90.0
counter_source: str = "manual" # auto_plc | auto_time | manual
installed_at: Optional[str] = None
class PartUpdate(BaseModel):
name: Optional[str] = None
part_number: Optional[str] = None
category: Optional[str] = None
lifecycle_type: Optional[str] = None
lifecycle_limit: Optional[float] = None
alarm_threshold: Optional[float] = None
counter_source: Optional[str] = None
def _format_ts(val) -> Optional[str]:
if val is None:
return None
return val.isoformat() if hasattr(val, "isoformat") else str(val)
def _part_to_dict(p: EquipmentPart, counter: Optional[PartCounter] = None) -> dict:
result = {
"id": str(p.id),
"tenant_id": str(p.tenant_id),
"machine_id": str(p.machine_id),
"name": str(p.name),
"part_number": str(p.part_number) if p.part_number else None,
"category": str(p.category) if p.category else None,
"lifecycle_type": str(p.lifecycle_type),
"lifecycle_limit": float(p.lifecycle_limit),
"alarm_threshold": float(p.alarm_threshold or 90.0),
"counter_source": str(p.counter_source or "manual"),
"installed_at": _format_ts(p.installed_at),
"is_active": bool(p.is_active),
"created_at": _format_ts(p.created_at),
"updated_at": _format_ts(p.updated_at),
}
if counter:
result["counter"] = {
"current_value": float(counter.current_value or 0),
"lifecycle_pct": float(counter.lifecycle_pct or 0),
"last_reset_at": _format_ts(counter.last_reset_at),
"last_updated_at": _format_ts(counter.last_updated_at),
"version": int(counter.version or 0),
}
return result
async def _verify_machine(
db: AsyncSession, tenant_id: str, machine_id: UUID
) -> Machine:
stmt = select(Machine).where(
Machine.id == machine_id, Machine.tenant_id == tenant_id
)
result = await db.execute(stmt)
machine = result.scalar_one_or_none()
if not machine:
raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다.")
return machine
@router.get("/api/{tenant_id}/machines/{machine_id}/parts")
async def list_parts(
tenant_id: str = Path(...),
machine_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
await _verify_machine(db, tenant_id, machine_id)
stmt = (
select(EquipmentPart)
.options(selectinload(EquipmentPart.counter))
.where(
EquipmentPart.machine_id == machine_id,
EquipmentPart.tenant_id == tenant_id,
EquipmentPart.is_active == True,
)
.order_by(EquipmentPart.name)
)
result = await db.execute(stmt)
parts = result.scalars().all()
return {"parts": [_part_to_dict(p, p.counter) for p in parts]}
@router.post("/api/{tenant_id}/machines/{machine_id}/parts")
async def create_part(
body: PartCreate,
tenant_id: str = Path(...),
machine_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
await _verify_machine(db, tenant_id, machine_id)
if body.lifecycle_type not in ("hours", "count", "date"):
raise HTTPException(
status_code=400,
detail="lifecycle_type은 hours, count, date 중 하나여야 합니다.",
)
if body.counter_source not in ("auto_plc", "auto_time", "manual"):
raise HTTPException(
status_code=400,
detail="counter_source는 auto_plc, auto_time, manual 중 하나여야 합니다.",
)
dupe_stmt = select(EquipmentPart).where(
EquipmentPart.tenant_id == tenant_id,
EquipmentPart.machine_id == machine_id,
EquipmentPart.name == body.name,
EquipmentPart.is_active == True,
)
if (await db.execute(dupe_stmt)).scalar_one_or_none():
raise HTTPException(
status_code=409, detail=f"같은 이름의 부품 '{body.name}'이 이미 존재합니다."
)
installed_dt = None
if body.installed_at:
try:
installed_dt = datetime.fromisoformat(
body.installed_at.replace("Z", "+00:00")
)
except ValueError:
raise HTTPException(
status_code=400,
detail="installed_at 형식이 올바르지 않습니다. ISO 8601 형식을 사용하세요.",
)
part = EquipmentPart(
tenant_id=tenant_id,
machine_id=machine_id,
name=body.name,
part_number=body.part_number,
category=body.category,
lifecycle_type=body.lifecycle_type,
lifecycle_limit=body.lifecycle_limit,
alarm_threshold=body.alarm_threshold,
counter_source=body.counter_source,
installed_at=installed_dt,
)
db.add(part)
await db.flush()
now = datetime.now(timezone.utc)
counter = PartCounter(
tenant_id=tenant_id,
equipment_part_id=part.id,
current_value=0,
lifecycle_pct=0,
last_reset_at=installed_dt or now,
last_updated_at=now,
)
db.add(counter)
await db.commit()
await db.refresh(part)
await db.refresh(counter)
return _part_to_dict(part, counter)
@router.get("/api/{tenant_id}/parts/{part_id}")
async def get_part(
tenant_id: str = Path(...),
part_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = (
select(EquipmentPart)
.options(selectinload(EquipmentPart.counter))
.where(EquipmentPart.id == part_id, EquipmentPart.tenant_id == tenant_id)
)
result = await db.execute(stmt)
part = result.scalar_one_or_none()
if not part:
raise HTTPException(status_code=404, detail="부품을 찾을 수 없습니다.")
return _part_to_dict(part, part.counter)
@router.put("/api/{tenant_id}/parts/{part_id}")
async def update_part(
body: PartUpdate,
tenant_id: str = Path(...),
part_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = select(EquipmentPart).where(
EquipmentPart.id == part_id, EquipmentPart.tenant_id == tenant_id
)
result = await db.execute(stmt)
part = result.scalar_one_or_none()
if not part:
raise HTTPException(status_code=404, detail="부품을 찾을 수 없습니다.")
if body.name is not None:
dupe_stmt = select(EquipmentPart).where(
EquipmentPart.tenant_id == tenant_id,
EquipmentPart.machine_id == part.machine_id,
EquipmentPart.name == body.name,
EquipmentPart.is_active == True,
EquipmentPart.id != part_id,
)
if (await db.execute(dupe_stmt)).scalar_one_or_none():
raise HTTPException(
status_code=409,
detail=f"같은 이름의 부품 '{body.name}'이 이미 존재합니다.",
)
part.name = body.name
if body.part_number is not None:
part.part_number = body.part_number
if body.category is not None:
part.category = body.category
if body.lifecycle_type is not None:
if body.lifecycle_type not in ("hours", "count", "date"):
raise HTTPException(
status_code=400,
detail="lifecycle_type은 hours, count, date 중 하나여야 합니다.",
)
part.lifecycle_type = body.lifecycle_type
if body.lifecycle_limit is not None:
part.lifecycle_limit = body.lifecycle_limit
if body.alarm_threshold is not None:
part.alarm_threshold = body.alarm_threshold
if body.counter_source is not None:
if body.counter_source not in ("auto_plc", "auto_time", "manual"):
raise HTTPException(
status_code=400,
detail="counter_source는 auto_plc, auto_time, manual 중 하나여야 합니다.",
)
part.counter_source = body.counter_source
await db.commit()
await db.refresh(part)
return _part_to_dict(part)
@router.delete("/api/{tenant_id}/parts/{part_id}")
async def delete_part(
tenant_id: str = Path(...),
part_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = select(EquipmentPart).where(
EquipmentPart.id == part_id, EquipmentPart.tenant_id == tenant_id
)
result = await db.execute(stmt)
part = result.scalar_one_or_none()
if not part:
raise HTTPException(status_code=404, detail="부품을 찾을 수 없습니다.")
part.is_active = False
await db.commit()
return {"status": "success", "message": "부품이 비활성화되었습니다."}

230
src/api/machines.py Normal file
View File

@@ -0,0 +1,230 @@
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends, Path
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.database.config import get_db
from src.database.models import Machine, EquipmentPart
from src.auth.models import TokenData
from src.auth.dependencies import require_auth, verify_tenant_access
router = APIRouter(prefix="/api/{tenant_id}/machines", tags=["machines"])
class MachineCreate(BaseModel):
name: str
equipment_code: str = ""
model: Optional[str] = None
class MachineUpdate(BaseModel):
name: Optional[str] = None
equipment_code: Optional[str] = None
model: Optional[str] = None
class MachineResponse(BaseModel):
id: str
tenant_id: str
name: str
equipment_code: str
model: Optional[str]
parts_count: int = 0
created_at: Optional[str] = None
updated_at: Optional[str] = None
model_config = {"from_attributes": True}
class MachineDetailResponse(MachineResponse):
parts: List[dict] = []
def _format_ts(val) -> Optional[str]:
if val is None:
return None
return val.isoformat() if hasattr(val, "isoformat") else str(val)
def _machine_to_response(m: Machine, parts_count: int = 0) -> MachineResponse:
return MachineResponse(
id=str(m.id),
tenant_id=str(m.tenant_id),
name=str(m.name),
equipment_code=str(m.equipment_code or ""),
model=str(m.model) if m.model else None,
parts_count=parts_count,
created_at=_format_ts(m.created_at),
updated_at=_format_ts(m.updated_at),
)
@router.get("")
async def list_machines(
tenant_id: str = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = (
select(Machine, func.count(EquipmentPart.id).label("parts_count"))
.outerjoin(
EquipmentPart,
(EquipmentPart.machine_id == Machine.id)
& (EquipmentPart.is_active == True),
)
.where(Machine.tenant_id == tenant_id)
.group_by(Machine.id)
.order_by(Machine.name)
)
result = await db.execute(stmt)
rows = result.all()
return {"machines": [_machine_to_response(m, count) for m, count in rows]}
@router.post("")
async def create_machine(
body: MachineCreate,
tenant_id: str = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
machine = Machine(
tenant_id=tenant_id,
name=body.name,
equipment_code=body.equipment_code,
model=body.model,
)
db.add(machine)
await db.commit()
await db.refresh(machine)
return _machine_to_response(machine, 0)
@router.get("/{machine_id}")
async def get_machine(
tenant_id: str = Path(...),
machine_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = (
select(Machine)
.options(selectinload(Machine.parts))
.where(Machine.id == machine_id, Machine.tenant_id == tenant_id)
)
result = await db.execute(stmt)
machine = result.scalar_one_or_none()
if not machine:
raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다.")
active_parts = [p for p in machine.parts if p.is_active]
parts_data = [
{
"id": str(p.id),
"name": str(p.name),
"part_number": str(p.part_number) if p.part_number else None,
"category": str(p.category) if p.category else None,
"lifecycle_type": str(p.lifecycle_type),
"lifecycle_limit": float(p.lifecycle_limit),
"alarm_threshold": float(p.alarm_threshold or 90.0),
"counter_source": str(p.counter_source or "manual"),
"installed_at": _format_ts(p.installed_at),
"is_active": bool(p.is_active),
}
for p in active_parts
]
resp = MachineDetailResponse(
id=str(machine.id),
tenant_id=str(machine.tenant_id),
name=str(machine.name),
equipment_code=str(machine.equipment_code or ""),
model=str(machine.model) if machine.model else None,
parts_count=len(active_parts),
parts=parts_data,
created_at=_format_ts(machine.created_at),
updated_at=_format_ts(machine.updated_at),
)
return resp
@router.put("/{machine_id}")
async def update_machine(
body: MachineUpdate,
tenant_id: str = Path(...),
machine_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = select(Machine).where(
Machine.id == machine_id, Machine.tenant_id == tenant_id
)
result = await db.execute(stmt)
machine = result.scalar_one_or_none()
if not machine:
raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다.")
if body.name is not None:
machine.name = body.name
if body.equipment_code is not None:
machine.equipment_code = body.equipment_code
if body.model is not None:
machine.model = body.model
await db.commit()
await db.refresh(machine)
return _machine_to_response(machine)
@router.delete("/{machine_id}")
async def delete_machine(
tenant_id: str = Path(...),
machine_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = select(Machine).where(
Machine.id == machine_id, Machine.tenant_id == tenant_id
)
result = await db.execute(stmt)
machine = result.scalar_one_or_none()
if not machine:
raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다.")
parts_stmt = select(func.count(EquipmentPart.id)).where(
EquipmentPart.machine_id == machine_id,
EquipmentPart.is_active == True,
)
parts_count = (await db.execute(parts_stmt)).scalar() or 0
if parts_count > 0:
raise HTTPException(
status_code=409,
detail=f"활성 부품이 {parts_count}개 있어 삭제할 수 없습니다. 먼저 부품을 제거해주세요.",
)
await db.delete(machine)
await db.commit()
return {"status": "success", "message": "설비가 삭제되었습니다."}

16
src/auth/__init__.py Normal file
View File

@@ -0,0 +1,16 @@
from src.auth.jwt_handler import create_access_token, decode_access_token
from src.auth.password import hash_password, verify_password
from src.auth.dependencies import get_current_user, require_auth, require_superadmin
from src.auth.router import router as auth_router, admin_router as auth_admin_router
__all__ = [
"create_access_token",
"decode_access_token",
"hash_password",
"verify_password",
"get_current_user",
"require_auth",
"require_superadmin",
"auth_router",
"auth_admin_router",
]

69
src/auth/dependencies.py Normal file
View File

@@ -0,0 +1,69 @@
from typing import Optional
from fastapi import Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from src.database.config import get_db
from src.auth.jwt_handler import decode_access_token
from src.auth.models import TokenData
from src.auth import service as auth_service
async def get_current_user(
request: Request,
db: AsyncSession = Depends(get_db),
) -> Optional[TokenData]:
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return None
token = auth_header.split(" ", 1)[1]
payload = decode_access_token(token)
if not payload:
return None
user = await auth_service.get_user_by_id(db, payload.get("user_id", ""))
if not user or not bool(user.is_active):
return None
return TokenData(
user_id=str(user.id),
email=str(user.email),
role=str(user.role),
tenant_id=str(user.tenant_id) if user.tenant_id is not None else None,
)
async def require_auth(
current_user: Optional[TokenData] = Depends(get_current_user),
) -> TokenData:
if not current_user:
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
return current_user
async def require_superadmin(
current_user: TokenData = Depends(require_auth),
) -> TokenData:
if current_user.role != "superadmin":
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
return current_user
def verify_tenant_access(tenant_id: str, current_user: TokenData) -> None:
if current_user.role == "superadmin":
return
if current_user.tenant_id != tenant_id:
raise HTTPException(
status_code=403, detail="해당 테넌트에 대한 접근 권한이 없습니다."
)
class TenantAccessChecker:
async def __call__(
self,
tenant_id: str,
current_user: TokenData = Depends(require_auth),
) -> TokenData:
verify_tenant_access(tenant_id, current_user)
return current_user

23
src/auth/jwt_handler.py Normal file
View File

@@ -0,0 +1,23 @@
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
import jwt
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-super-secret-key")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 24
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_access_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None

46
src/auth/models.py Normal file
View File

@@ -0,0 +1,46 @@
from typing import Optional
from pydantic import BaseModel
class UserLogin(BaseModel):
email: str
password: str
class UserCreate(BaseModel):
email: str
password: str
name: str
role: str = "user"
tenant_id: Optional[str] = None
class UserUpdate(BaseModel):
name: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
class UserResponse(BaseModel):
id: str
email: str
name: str
role: str
tenant_id: Optional[str] = None
is_active: bool
model_config = {"from_attributes": True}
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserResponse
class TokenData(BaseModel):
user_id: str
email: str
role: str
tenant_id: Optional[str] = None

9
src/auth/password.py Normal file
View File

@@ -0,0 +1,9 @@
import bcrypt
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))

102
src/auth/router.py Normal file
View File

@@ -0,0 +1,102 @@
from typing import List
from fastapi import APIRouter, HTTPException, Response, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from src.database.config import get_db
from src.auth.models import UserLogin, UserCreate, UserResponse, Token, TokenData
from src.auth import service as auth_service
from src.auth.dependencies import require_auth, require_superadmin
router = APIRouter(prefix="/api/auth", tags=["auth"])
admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.post("/login", response_model=Token)
async def login(
credentials: UserLogin,
response: Response,
db: AsyncSession = Depends(get_db),
):
"""로그인 - 이메일/비밀번호로 JWT 토큰 발급"""
result = await auth_service.login(db, credentials.email, credentials.password)
if not result:
raise HTTPException(
status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다."
)
response.set_cookie(
key="access_token",
value=result.access_token,
httponly=True,
max_age=86400,
samesite="lax",
)
return result
@router.post("/logout")
async def logout(response: Response):
"""로그아웃"""
response.delete_cookie(key="access_token")
return {"status": "success", "message": "로그아웃되었습니다."}
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
"""현재 로그인한 사용자 정보"""
user = await auth_service.get_user_by_id(db, current_user.user_id)
if not user:
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
return UserResponse(
id=str(user.id),
email=str(user.email),
name=str(user.name),
role=str(user.role),
tenant_id=str(user.tenant_id) if user.tenant_id is not None else None,
is_active=bool(user.is_active),
)
@admin_router.get("/users", response_model=List[UserResponse])
async def list_all_users(
current_user: TokenData = Depends(require_superadmin),
db: AsyncSession = Depends(get_db),
):
"""전체 사용자 목록 (superadmin 전용)"""
users = await auth_service.list_users(db)
return [
UserResponse(
id=str(u.id),
email=str(u.email),
name=str(u.name),
role=str(u.role),
tenant_id=str(u.tenant_id) if u.tenant_id is not None else None,
is_active=bool(u.is_active),
)
for u in users
]
@admin_router.post("/users", response_model=UserResponse)
async def create_user(
user_data: UserCreate,
current_user: TokenData = Depends(require_superadmin),
db: AsyncSession = Depends(get_db),
):
"""사용자 생성 (superadmin 전용)"""
try:
user = await auth_service.create_user(db, user_data)
return UserResponse(
id=str(user.id),
email=str(user.email),
name=str(user.name),
role=str(user.role),
tenant_id=str(user.tenant_id) if user.tenant_id is not None else None,
is_active=bool(user.is_active),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))

75
src/auth/service.py Normal file
View File

@@ -0,0 +1,75 @@
import uuid
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.database.models import User
from src.auth.jwt_handler import create_access_token
from src.auth.password import hash_password, verify_password
from src.auth.models import UserCreate, UserResponse, Token, TokenData
async def login(db: AsyncSession, email: str, password: str) -> Optional[Token]:
user = await get_user_by_email(db, email)
if not user or not verify_password(password, str(user.password_hash)):
return None
if not bool(user.is_active):
return None
token_data = {
"user_id": str(user.id),
"email": str(user.email),
"role": str(user.role),
"tenant_id": str(user.tenant_id) if user.tenant_id is not None else None,
}
access_token = create_access_token(token_data)
return Token(
access_token=access_token,
user=UserResponse(
id=str(user.id),
email=str(user.email),
name=str(user.name),
role=str(user.role),
tenant_id=str(user.tenant_id) if user.tenant_id is not None else None,
is_active=bool(user.is_active),
),
)
async def get_user_by_id(db: AsyncSession, user_id: str) -> Optional[User]:
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]:
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def create_user(db: AsyncSession, data: UserCreate) -> User:
existing = await get_user_by_email(db, data.email)
if existing:
raise ValueError(f"Email already exists: {data.email}")
user = User(
id=uuid.uuid4(),
email=data.email,
password_hash=hash_password(data.password),
name=data.name,
role=data.role,
tenant_id=data.tenant_id,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
async def list_users(db: AsyncSession, tenant_id: Optional[str] = None) -> List[User]:
stmt = select(User)
if tenant_id:
stmt = stmt.where(User.tenant_id == tenant_id)
result = await db.execute(stmt.order_by(User.created_at.desc()))
return list(result.scalars().all())

0
src/database/__init__.py Normal file
View File

39
src/database/config.py Normal file
View File

@@ -0,0 +1,39 @@
"""PostgreSQL async 데이터베이스 설정"""
import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://factoryops:factoryops@localhost:5432/factoryops_v2",
)
engine = create_async_engine(
DATABASE_URL,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
echo=os.getenv("SQL_ECHO", "false").lower() == "true",
)
AsyncSessionLocal = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
Base = declarative_base()
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def init_db():
from src.database.models import Base as _Base # noqa: F811
async with engine.begin() as conn:
await conn.run_sync(_Base.metadata.create_all)

166
src/database/models.py Normal file
View File

@@ -0,0 +1,166 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import (
Column,
String,
Boolean,
Float,
Integer,
ForeignKey,
Index,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import UUID, JSONB, TIMESTAMP
from sqlalchemy.orm import relationship
from src.database.config import Base
def utcnow():
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
name = Column(String(100), nullable=False)
role = Column(String(20), nullable=False) # superadmin | tenant_admin | user
tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
updated_at = Column(TIMESTAMP(timezone=True), default=utcnow, onupdate=utcnow)
tenant = relationship("Tenant", back_populates="users")
class Tenant(Base):
__tablename__ = "tenants"
id = Column(String(50), primary_key=True)
name = Column(String(100), nullable=False)
industry_type = Column(String(50), default="general")
is_active = Column(Boolean, default=True)
enabled_modules = Column(JSONB, nullable=True)
workflow_config = Column(JSONB, nullable=True)
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
users = relationship("User", back_populates="tenant")
machines = relationship("Machine", back_populates="tenant")
class Machine(Base):
__tablename__ = "machines"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False)
name = Column(String(100), nullable=False)
equipment_code = Column(String(50), default="")
model = Column(String(100), nullable=True)
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
updated_at = Column(TIMESTAMP(timezone=True), default=utcnow, onupdate=utcnow)
tenant = relationship("Tenant", back_populates="machines")
parts = relationship(
"EquipmentPart", back_populates="machine", cascade="all, delete-orphan"
)
__table_args__ = (Index("ix_machines_tenant_id", "tenant_id"),)
class EquipmentPart(Base):
__tablename__ = "equipment_parts"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False)
machine_id = Column(UUID(as_uuid=True), ForeignKey("machines.id"), nullable=False)
name = Column(String(100), nullable=False)
part_number = Column(String(50), nullable=True)
category = Column(String(50), nullable=True)
lifecycle_type = Column(String(20), nullable=False) # hours | count | date
lifecycle_limit = Column(Float, nullable=False)
alarm_threshold = Column(Float, default=90.0)
counter_source = Column(
String(20), default="manual"
) # auto_plc | auto_time | manual
installed_at = Column(TIMESTAMP(timezone=True), nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
updated_at = Column(TIMESTAMP(timezone=True), default=utcnow, onupdate=utcnow)
tenant = relationship("Tenant")
machine = relationship("Machine", back_populates="parts")
counter = relationship(
"PartCounter",
back_populates="equipment_part",
uselist=False,
cascade="all, delete-orphan",
)
replacement_logs = relationship(
"PartReplacementLog",
back_populates="equipment_part",
cascade="all, delete-orphan",
)
__table_args__ = (
Index(
"uq_equipment_part_active_tenant_machine_name",
"tenant_id",
"machine_id",
"name",
unique=True,
postgresql_where=text("is_active = true"),
),
Index("ix_equipment_parts_tenant_id", "tenant_id"),
Index("ix_equipment_parts_tenant_machine", "tenant_id", "machine_id"),
)
class PartCounter(Base):
__tablename__ = "part_counters"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False)
equipment_part_id = Column(
UUID(as_uuid=True),
ForeignKey("equipment_parts.id"),
unique=True,
nullable=False,
)
current_value = Column(Float, default=0)
lifecycle_pct = Column(Float, default=0)
last_reset_at = Column(TIMESTAMP(timezone=True), nullable=False)
last_updated_at = Column(TIMESTAMP(timezone=True), nullable=False)
version = Column(Integer, default=0)
equipment_part = relationship("EquipmentPart", back_populates="counter")
__table_args__ = (Index("ix_part_counters_tenant_id", "tenant_id"),)
class PartReplacementLog(Base):
__tablename__ = "part_replacement_logs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False)
equipment_part_id = Column(
UUID(as_uuid=True), ForeignKey("equipment_parts.id"), nullable=False
)
replaced_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
replaced_at = Column(TIMESTAMP(timezone=True), nullable=False)
counter_at_replacement = Column(Float, nullable=False)
lifecycle_pct_at_replacement = Column(Float, nullable=False)
reason = Column(String(200), nullable=True)
notes = Column(Text, nullable=True)
equipment_part = relationship("EquipmentPart", back_populates="replacement_logs")
__table_args__ = (
Index("ix_part_replacement_tenant_part", "tenant_id", "equipment_part_id"),
Index("ix_part_replacement_tenant_date", "tenant_id", "replaced_at"),
)

0
src/services/__init__.py Normal file
View File

21
src/tenant/__init__.py Normal file
View File

@@ -0,0 +1,21 @@
from src.tenant.manager import (
get_tenant,
tenant_exists,
create_tenant,
list_tenants,
update_tenant,
validate_tenant_id,
TenantNotFoundError,
InvalidTenantIdError,
)
__all__ = [
"get_tenant",
"tenant_exists",
"create_tenant",
"list_tenants",
"update_tenant",
"validate_tenant_id",
"TenantNotFoundError",
"InvalidTenantIdError",
]

136
src/tenant/manager.py Normal file
View File

@@ -0,0 +1,136 @@
import re
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.database.models import Tenant
def _format_timestamp(val: Any) -> Optional[str]:
if val is None:
return None
return str(val.isoformat()) if hasattr(val, "isoformat") else str(val)
TENANT_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$")
class TenantNotFoundError(Exception):
pass
class InvalidTenantIdError(Exception):
pass
def validate_tenant_id(tenant_id: str) -> bool:
if not tenant_id or len(tenant_id) < 3 or len(tenant_id) > 32:
return False
return bool(TENANT_ID_PATTERN.match(tenant_id))
async def get_tenant(db: AsyncSession, tenant_id: str) -> Dict:
if not validate_tenant_id(tenant_id):
raise InvalidTenantIdError(f"유효하지 않은 테넌트 ID: {tenant_id}")
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
tenant = result.scalar_one_or_none()
if not tenant:
raise TenantNotFoundError(f"테넌트를 찾을 수 없습니다: {tenant_id}")
return {
"id": str(tenant.id),
"name": str(tenant.name),
"industry_type": str(tenant.industry_type),
"is_active": bool(tenant.is_active),
"created_at": _format_timestamp(tenant.created_at),
}
async def tenant_exists(db: AsyncSession, tenant_id: str) -> bool:
if not validate_tenant_id(tenant_id):
return False
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
return result.scalar_one_or_none() is not None
async def create_tenant(
db: AsyncSession,
tenant_id: str,
name: str,
industry_type: str = "general",
enabled_modules: Optional[dict] = None,
workflow_config: Optional[dict] = None,
) -> Dict:
if not validate_tenant_id(tenant_id):
raise InvalidTenantIdError(f"유효하지 않은 테넌트 ID: {tenant_id}")
existing = await tenant_exists(db, tenant_id)
if existing:
raise ValueError(f"이미 존재하는 테넌트입니다: {tenant_id}")
tenant = Tenant(
id=tenant_id,
name=name,
industry_type=industry_type,
enabled_modules=enabled_modules,
workflow_config=workflow_config,
)
db.add(tenant)
await db.commit()
await db.refresh(tenant)
return {
"id": str(tenant.id),
"name": str(tenant.name),
"industry_type": str(tenant.industry_type),
"is_active": bool(tenant.is_active),
"created_at": _format_timestamp(tenant.created_at),
}
async def list_tenants(db: AsyncSession) -> List[Dict]:
result = await db.execute(select(Tenant).order_by(Tenant.created_at.desc()))
tenants = result.scalars().all()
return [
{
"id": str(t.id),
"name": str(t.name),
"industry_type": str(t.industry_type),
"is_active": bool(t.is_active),
"created_at": _format_timestamp(t.created_at),
}
for t in tenants
]
async def update_tenant(
db: AsyncSession,
tenant_id: str,
name: Optional[str] = None,
is_active: Optional[bool] = None,
) -> Dict:
if not validate_tenant_id(tenant_id):
raise InvalidTenantIdError(f"유효하지 않은 테넌트 ID: {tenant_id}")
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
tenant = result.scalar_one_or_none()
if not tenant:
raise TenantNotFoundError(f"테넌트를 찾을 수 없습니다: {tenant_id}")
if name is not None:
tenant.name = name # type: ignore[assignment]
if is_active is not None:
tenant.is_active = is_active # type: ignore[assignment]
await db.commit()
await db.refresh(tenant)
return {
"id": str(tenant.id),
"name": str(tenant.name),
"industry_type": str(tenant.industry_type),
"is_active": bool(tenant.is_active),
"created_at": _format_timestamp(tenant.created_at),
}