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

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),
}