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:
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