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:
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
0
src/api/__init__.py
Normal file
0
src/api/__init__.py
Normal file
299
src/api/equipment_parts.py
Normal file
299
src/api/equipment_parts.py
Normal 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
230
src/api/machines.py
Normal 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
16
src/auth/__init__.py
Normal 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
69
src/auth/dependencies.py
Normal 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
23
src/auth/jwt_handler.py
Normal 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
46
src/auth/models.py
Normal 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
9
src/auth/password.py
Normal 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
102
src/auth/router.py
Normal 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
75
src/auth/service.py
Normal 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
0
src/database/__init__.py
Normal file
39
src/database/config.py
Normal file
39
src/database/config.py
Normal 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
166
src/database/models.py
Normal 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
0
src/services/__init__.py
Normal file
21
src/tenant/__init__.py
Normal file
21
src/tenant/__init__.py
Normal 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
136
src/tenant/manager.py
Normal 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),
|
||||
}
|
||||
Reference in New Issue
Block a user