@weirdfingers/baseboards 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +191 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +887 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/templates/README.md +120 -0
- package/templates/api/.env.example +62 -0
- package/templates/api/Dockerfile +32 -0
- package/templates/api/README.md +132 -0
- package/templates/api/alembic/env.py +106 -0
- package/templates/api/alembic/script.py.mako +28 -0
- package/templates/api/alembic/versions/20250101_000000_initial_schema.py +448 -0
- package/templates/api/alembic/versions/20251022_174729_remove_provider_name_from_generations.py +71 -0
- package/templates/api/alembic/versions/20251023_165852_switch_to_declarative_base_and_mapping.py +411 -0
- package/templates/api/alembic/versions/2025925_62735_add_seed_data_for_default_tenant.py +85 -0
- package/templates/api/alembic.ini +36 -0
- package/templates/api/config/generators.yaml +25 -0
- package/templates/api/config/storage_config.yaml +26 -0
- package/templates/api/docs/ADDING_GENERATORS.md +409 -0
- package/templates/api/docs/GENERATORS_API.md +502 -0
- package/templates/api/docs/MIGRATIONS.md +472 -0
- package/templates/api/docs/storage_providers.md +337 -0
- package/templates/api/pyproject.toml +165 -0
- package/templates/api/src/boards/__init__.py +10 -0
- package/templates/api/src/boards/api/app.py +171 -0
- package/templates/api/src/boards/api/auth.py +75 -0
- package/templates/api/src/boards/api/endpoints/__init__.py +3 -0
- package/templates/api/src/boards/api/endpoints/jobs.py +76 -0
- package/templates/api/src/boards/api/endpoints/setup.py +505 -0
- package/templates/api/src/boards/api/endpoints/sse.py +129 -0
- package/templates/api/src/boards/api/endpoints/storage.py +74 -0
- package/templates/api/src/boards/api/endpoints/tenant_registration.py +296 -0
- package/templates/api/src/boards/api/endpoints/webhooks.py +13 -0
- package/templates/api/src/boards/auth/__init__.py +15 -0
- package/templates/api/src/boards/auth/adapters/__init__.py +20 -0
- package/templates/api/src/boards/auth/adapters/auth0.py +220 -0
- package/templates/api/src/boards/auth/adapters/base.py +73 -0
- package/templates/api/src/boards/auth/adapters/clerk.py +172 -0
- package/templates/api/src/boards/auth/adapters/jwt.py +122 -0
- package/templates/api/src/boards/auth/adapters/none.py +102 -0
- package/templates/api/src/boards/auth/adapters/oidc.py +284 -0
- package/templates/api/src/boards/auth/adapters/supabase.py +110 -0
- package/templates/api/src/boards/auth/context.py +35 -0
- package/templates/api/src/boards/auth/factory.py +115 -0
- package/templates/api/src/boards/auth/middleware.py +221 -0
- package/templates/api/src/boards/auth/provisioning.py +129 -0
- package/templates/api/src/boards/auth/tenant_extraction.py +278 -0
- package/templates/api/src/boards/cli.py +354 -0
- package/templates/api/src/boards/config.py +116 -0
- package/templates/api/src/boards/database/__init__.py +7 -0
- package/templates/api/src/boards/database/cli.py +110 -0
- package/templates/api/src/boards/database/connection.py +252 -0
- package/templates/api/src/boards/database/models.py +19 -0
- package/templates/api/src/boards/database/seed_data.py +182 -0
- package/templates/api/src/boards/dbmodels/__init__.py +455 -0
- package/templates/api/src/boards/generators/__init__.py +57 -0
- package/templates/api/src/boards/generators/artifacts.py +53 -0
- package/templates/api/src/boards/generators/base.py +140 -0
- package/templates/api/src/boards/generators/implementations/__init__.py +12 -0
- package/templates/api/src/boards/generators/implementations/audio/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/audio/whisper.py +66 -0
- package/templates/api/src/boards/generators/implementations/image/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/image/dalle3.py +93 -0
- package/templates/api/src/boards/generators/implementations/image/flux_pro.py +85 -0
- package/templates/api/src/boards/generators/implementations/video/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/video/lipsync.py +70 -0
- package/templates/api/src/boards/generators/loader.py +253 -0
- package/templates/api/src/boards/generators/registry.py +114 -0
- package/templates/api/src/boards/generators/resolution.py +515 -0
- package/templates/api/src/boards/generators/testmods/class_gen.py +34 -0
- package/templates/api/src/boards/generators/testmods/import_side_effect.py +35 -0
- package/templates/api/src/boards/graphql/__init__.py +7 -0
- package/templates/api/src/boards/graphql/access_control.py +136 -0
- package/templates/api/src/boards/graphql/mutations/root.py +136 -0
- package/templates/api/src/boards/graphql/queries/root.py +116 -0
- package/templates/api/src/boards/graphql/resolvers/__init__.py +8 -0
- package/templates/api/src/boards/graphql/resolvers/auth.py +12 -0
- package/templates/api/src/boards/graphql/resolvers/board.py +1055 -0
- package/templates/api/src/boards/graphql/resolvers/generation.py +889 -0
- package/templates/api/src/boards/graphql/resolvers/generator.py +50 -0
- package/templates/api/src/boards/graphql/resolvers/user.py +25 -0
- package/templates/api/src/boards/graphql/schema.py +81 -0
- package/templates/api/src/boards/graphql/types/board.py +102 -0
- package/templates/api/src/boards/graphql/types/generation.py +130 -0
- package/templates/api/src/boards/graphql/types/generator.py +17 -0
- package/templates/api/src/boards/graphql/types/user.py +47 -0
- package/templates/api/src/boards/jobs/repository.py +104 -0
- package/templates/api/src/boards/logging.py +195 -0
- package/templates/api/src/boards/middleware.py +339 -0
- package/templates/api/src/boards/progress/__init__.py +4 -0
- package/templates/api/src/boards/progress/models.py +25 -0
- package/templates/api/src/boards/progress/publisher.py +64 -0
- package/templates/api/src/boards/py.typed +0 -0
- package/templates/api/src/boards/redis_pool.py +118 -0
- package/templates/api/src/boards/storage/__init__.py +52 -0
- package/templates/api/src/boards/storage/base.py +363 -0
- package/templates/api/src/boards/storage/config.py +187 -0
- package/templates/api/src/boards/storage/factory.py +278 -0
- package/templates/api/src/boards/storage/implementations/__init__.py +27 -0
- package/templates/api/src/boards/storage/implementations/gcs.py +340 -0
- package/templates/api/src/boards/storage/implementations/local.py +201 -0
- package/templates/api/src/boards/storage/implementations/s3.py +294 -0
- package/templates/api/src/boards/storage/implementations/supabase.py +218 -0
- package/templates/api/src/boards/tenant_isolation.py +446 -0
- package/templates/api/src/boards/validation.py +262 -0
- package/templates/api/src/boards/workers/__init__.py +1 -0
- package/templates/api/src/boards/workers/actors.py +201 -0
- package/templates/api/src/boards/workers/cli.py +125 -0
- package/templates/api/src/boards/workers/context.py +188 -0
- package/templates/api/src/boards/workers/middleware.py +58 -0
- package/templates/api/src/py.typed +0 -0
- package/templates/compose.dev.yaml +39 -0
- package/templates/compose.yaml +109 -0
- package/templates/docker/env.example +23 -0
- package/templates/web/.env.example +28 -0
- package/templates/web/Dockerfile +51 -0
- package/templates/web/components.json +22 -0
- package/templates/web/imageLoader.js +18 -0
- package/templates/web/next-env.d.ts +5 -0
- package/templates/web/next.config.js +36 -0
- package/templates/web/package.json +37 -0
- package/templates/web/postcss.config.mjs +7 -0
- package/templates/web/public/favicon.ico +0 -0
- package/templates/web/src/app/boards/[boardId]/page.tsx +232 -0
- package/templates/web/src/app/globals.css +120 -0
- package/templates/web/src/app/layout.tsx +21 -0
- package/templates/web/src/app/page.tsx +35 -0
- package/templates/web/src/app/providers.tsx +18 -0
- package/templates/web/src/components/boards/ArtifactInputSlots.tsx +142 -0
- package/templates/web/src/components/boards/ArtifactPreview.tsx +125 -0
- package/templates/web/src/components/boards/GenerationGrid.tsx +45 -0
- package/templates/web/src/components/boards/GenerationInput.tsx +251 -0
- package/templates/web/src/components/boards/GeneratorSelector.tsx +89 -0
- package/templates/web/src/components/header.tsx +30 -0
- package/templates/web/src/components/ui/button.tsx +58 -0
- package/templates/web/src/components/ui/card.tsx +92 -0
- package/templates/web/src/components/ui/navigation-menu.tsx +168 -0
- package/templates/web/src/lib/utils.ts +6 -0
- package/templates/web/tsconfig.json +47 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Authentication dependencies for API endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, HTTPException
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from ..auth import AuthContext, get_auth_context, get_auth_context_optional
|
|
11
|
+
from ..logging import get_logger
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthenticatedUser(BaseModel):
|
|
17
|
+
"""Represents an authenticated user."""
|
|
18
|
+
|
|
19
|
+
user_id: UUID
|
|
20
|
+
tenant_id: UUID
|
|
21
|
+
email: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def get_current_user(
|
|
25
|
+
auth_context: AuthContext = Depends(get_auth_context),
|
|
26
|
+
) -> AuthenticatedUser:
|
|
27
|
+
"""
|
|
28
|
+
Get the current authenticated user from the auth context.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
auth_context: Authentication context from middleware
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
AuthenticatedUser object with user information
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
HTTPException: If user is not authenticated
|
|
38
|
+
"""
|
|
39
|
+
if not auth_context.is_authenticated or not auth_context.user_id:
|
|
40
|
+
raise HTTPException(
|
|
41
|
+
status_code=401,
|
|
42
|
+
detail="Authentication required",
|
|
43
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return AuthenticatedUser(
|
|
47
|
+
user_id=auth_context.user_id,
|
|
48
|
+
tenant_id=auth_context.tenant_id,
|
|
49
|
+
email=auth_context.principal.get("email") if auth_context.principal else None,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def get_current_user_optional(
|
|
54
|
+
auth_context: AuthContext = Depends(get_auth_context_optional),
|
|
55
|
+
) -> AuthenticatedUser | None:
|
|
56
|
+
"""
|
|
57
|
+
Optional authentication - returns None if not authenticated.
|
|
58
|
+
|
|
59
|
+
Use this for endpoints that can work both authenticated and unauthenticated,
|
|
60
|
+
but may provide different functionality based on auth status.
|
|
61
|
+
"""
|
|
62
|
+
if not auth_context.is_authenticated or not auth_context.user_id:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
return AuthenticatedUser(
|
|
66
|
+
user_id=auth_context.user_id,
|
|
67
|
+
tenant_id=auth_context.tenant_id,
|
|
68
|
+
email=auth_context.principal.get("email") if auth_context.principal else None,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Legacy support - keep the old function names for backward compatibility
|
|
73
|
+
async def get_auth_context_dependency() -> AuthContext:
|
|
74
|
+
"""Get auth context directly (for advanced use cases)."""
|
|
75
|
+
return await get_auth_context()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Job submission endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from ...database.connection import get_db_session
|
|
12
|
+
from ...jobs import repository as jobs_repo
|
|
13
|
+
from ...logging import get_logger
|
|
14
|
+
from ...workers.actors import process_generation
|
|
15
|
+
from ..auth import AuthenticatedUser, get_current_user
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
router = APIRouter()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SubmitGenerationRequest(BaseModel):
|
|
24
|
+
tenant_id: UUID
|
|
25
|
+
board_id: UUID
|
|
26
|
+
user_id: UUID
|
|
27
|
+
generator_name: str
|
|
28
|
+
artifact_type: str
|
|
29
|
+
input_params: dict
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SubmitGenerationResponse(BaseModel):
|
|
33
|
+
generation_id: UUID
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.post("/generations", response_model=SubmitGenerationResponse)
|
|
37
|
+
async def submit_generation(
|
|
38
|
+
body: SubmitGenerationRequest,
|
|
39
|
+
db: AsyncSession = Depends(get_db_session),
|
|
40
|
+
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
41
|
+
) -> SubmitGenerationResponse:
|
|
42
|
+
"""Submit a new generation job.
|
|
43
|
+
|
|
44
|
+
Requires authentication. The authenticated user's ID and tenant will be used
|
|
45
|
+
for the generation, overriding any values provided in the request body.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
# Validate that user has access to the specified board
|
|
49
|
+
# TODO: Add board access validation logic here
|
|
50
|
+
|
|
51
|
+
# Override user_id and tenant_id with authenticated values for security
|
|
52
|
+
# This prevents users from submitting jobs on behalf of others
|
|
53
|
+
gen = await jobs_repo.create_generation(
|
|
54
|
+
db,
|
|
55
|
+
tenant_id=current_user.tenant_id, # Use authenticated tenant
|
|
56
|
+
board_id=body.board_id,
|
|
57
|
+
user_id=current_user.user_id, # Use authenticated user
|
|
58
|
+
generator_name=body.generator_name,
|
|
59
|
+
artifact_type=body.artifact_type,
|
|
60
|
+
input_params=body.input_params,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Commit the transaction to ensure job is persisted before enqueueing
|
|
64
|
+
await db.commit()
|
|
65
|
+
logger.info(f"Created generation job {gen.id} for user {current_user.user_id}")
|
|
66
|
+
|
|
67
|
+
# Enqueue job for processing
|
|
68
|
+
process_generation.send(str(gen.id))
|
|
69
|
+
logger.info(f"Enqueued generation job {gen.id}")
|
|
70
|
+
|
|
71
|
+
return SubmitGenerationResponse(generation_id=gen.id)
|
|
72
|
+
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.error(f"Failed to submit generation: {e}")
|
|
75
|
+
await db.rollback()
|
|
76
|
+
raise HTTPException(status_code=500, detail=f"Failed to submit generation: {str(e)}") from e
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Setup endpoints for initial tenant configuration.
|
|
3
|
+
|
|
4
|
+
These endpoints help with one-time setup for single-tenant deployments
|
|
5
|
+
or initial configuration of multi-tenant environments.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import UTC
|
|
11
|
+
from typing import Any
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, HTTPException, Path
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
from sqlalchemy import delete, select, update
|
|
17
|
+
|
|
18
|
+
from ...config import settings
|
|
19
|
+
from ...database.connection import get_async_session
|
|
20
|
+
from ...database.seed_data import ensure_tenant, seed_tenant_with_data
|
|
21
|
+
from ...dbmodels import Tenants
|
|
22
|
+
from ...logging import get_logger
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
router = APIRouter()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TenantSetupRequest(BaseModel):
|
|
30
|
+
"""Request model for tenant setup."""
|
|
31
|
+
|
|
32
|
+
name: str = Field(..., min_length=1, max_length=255, description="Display name for the tenant")
|
|
33
|
+
slug: str = Field(
|
|
34
|
+
...,
|
|
35
|
+
min_length=1,
|
|
36
|
+
max_length=255,
|
|
37
|
+
pattern=r"^[a-z0-9-]+$",
|
|
38
|
+
description="URL-safe slug for the tenant (lowercase, numbers, hyphens only)",
|
|
39
|
+
)
|
|
40
|
+
settings: dict[str, Any] = Field(
|
|
41
|
+
default_factory=dict, description="Optional tenant-specific settings"
|
|
42
|
+
)
|
|
43
|
+
include_sample_data: bool = Field(
|
|
44
|
+
default=False, description="Whether to include sample boards and data"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TenantUpdateRequest(BaseModel):
|
|
49
|
+
"""Request model for tenant updates."""
|
|
50
|
+
|
|
51
|
+
name: str | None = Field(
|
|
52
|
+
default=None,
|
|
53
|
+
min_length=1,
|
|
54
|
+
max_length=255,
|
|
55
|
+
description="Display name for the tenant",
|
|
56
|
+
)
|
|
57
|
+
slug: str | None = Field(
|
|
58
|
+
None,
|
|
59
|
+
min_length=1,
|
|
60
|
+
max_length=255,
|
|
61
|
+
pattern=r"^[a-z0-9-]+$",
|
|
62
|
+
description="URL-safe slug for the tenant (lowercase, numbers, hyphens only)",
|
|
63
|
+
)
|
|
64
|
+
settings: dict[str, Any] | None = Field(None, description="Tenant-specific settings")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TenantResponse(BaseModel):
|
|
68
|
+
"""Response model for tenant operations."""
|
|
69
|
+
|
|
70
|
+
tenant_id: str = Field(..., description="UUID of the tenant")
|
|
71
|
+
name: str = Field(..., description="Display name of the tenant")
|
|
72
|
+
slug: str = Field(..., description="Slug of the tenant")
|
|
73
|
+
settings: dict[str, Any] = Field(..., description="Tenant-specific settings")
|
|
74
|
+
created_at: str = Field(..., description="Creation timestamp")
|
|
75
|
+
updated_at: str = Field(..., description="Last update timestamp")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TenantSetupResponse(BaseModel):
|
|
79
|
+
"""Response model for tenant setup."""
|
|
80
|
+
|
|
81
|
+
tenant_id: str = Field(..., description="UUID of the created tenant")
|
|
82
|
+
name: str = Field(..., description="Display name of the tenant")
|
|
83
|
+
slug: str = Field(..., description="Slug of the tenant")
|
|
84
|
+
message: str = Field(..., description="Success message")
|
|
85
|
+
existing: bool = Field(..., description="Whether tenant already existed")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TenantListResponse(BaseModel):
|
|
89
|
+
"""Response model for tenant list."""
|
|
90
|
+
|
|
91
|
+
tenants: list[TenantResponse] = Field(..., description="List of tenants")
|
|
92
|
+
total_count: int = Field(..., description="Total number of tenants")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.post("/tenant", response_model=TenantSetupResponse)
|
|
96
|
+
async def setup_tenant(request: TenantSetupRequest) -> TenantSetupResponse:
|
|
97
|
+
"""
|
|
98
|
+
Create or configure a tenant for initial setup.
|
|
99
|
+
|
|
100
|
+
This endpoint is useful for:
|
|
101
|
+
- Single-tenant initial setup
|
|
102
|
+
- Creating new tenants in multi-tenant mode
|
|
103
|
+
- Demo/development tenant creation
|
|
104
|
+
|
|
105
|
+
In single-tenant mode, this is typically called once during deployment.
|
|
106
|
+
In multi-tenant mode, this can be used for admin tenant creation.
|
|
107
|
+
"""
|
|
108
|
+
logger.info(
|
|
109
|
+
"Setting up tenant",
|
|
110
|
+
name=request.name,
|
|
111
|
+
slug=request.slug,
|
|
112
|
+
include_sample_data=request.include_sample_data,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
async with get_async_session() as db:
|
|
117
|
+
# Check if tenant already exists
|
|
118
|
+
existing_tenant_id = await ensure_tenant(
|
|
119
|
+
db,
|
|
120
|
+
name=request.name,
|
|
121
|
+
slug=request.slug,
|
|
122
|
+
settings_dict=request.settings,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# For now, we'll consider it "existing" if the ensure_tenant call
|
|
126
|
+
# found an existing tenant. We could enhance this by checking
|
|
127
|
+
# if the tenant was created or updated.
|
|
128
|
+
try:
|
|
129
|
+
# Try to create with sample data if requested
|
|
130
|
+
if request.include_sample_data:
|
|
131
|
+
tenant_id = await seed_tenant_with_data(
|
|
132
|
+
db,
|
|
133
|
+
tenant_name=request.name,
|
|
134
|
+
tenant_slug=request.slug,
|
|
135
|
+
tenant_settings=request.settings,
|
|
136
|
+
include_sample_data=True,
|
|
137
|
+
)
|
|
138
|
+
existing = False # seed_tenant_with_data creates new tenant
|
|
139
|
+
else:
|
|
140
|
+
tenant_id = existing_tenant_id
|
|
141
|
+
existing = True # assume it existed (could be enhanced)
|
|
142
|
+
|
|
143
|
+
except Exception as e:
|
|
144
|
+
# If seeding fails, we still have the basic tenant
|
|
145
|
+
logger.warning(
|
|
146
|
+
"Sample data creation failed, but tenant was created",
|
|
147
|
+
error=str(e),
|
|
148
|
+
tenant_id=str(existing_tenant_id),
|
|
149
|
+
)
|
|
150
|
+
tenant_id = existing_tenant_id
|
|
151
|
+
existing = True
|
|
152
|
+
|
|
153
|
+
logger.info(
|
|
154
|
+
"Tenant setup completed",
|
|
155
|
+
tenant_id=str(tenant_id),
|
|
156
|
+
name=request.name,
|
|
157
|
+
slug=request.slug,
|
|
158
|
+
existing=existing,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return TenantSetupResponse(
|
|
162
|
+
tenant_id=str(tenant_id),
|
|
163
|
+
name=request.name,
|
|
164
|
+
slug=request.slug,
|
|
165
|
+
message=f"Tenant {'configured' if existing else 'created'} successfully",
|
|
166
|
+
existing=existing,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.error("Tenant setup failed", error=str(e))
|
|
171
|
+
raise HTTPException(status_code=500, detail=f"Failed to setup tenant: {str(e)}") from e
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@router.get("/tenant/{tenant_id}", response_model=TenantResponse)
|
|
175
|
+
async def get_tenant(
|
|
176
|
+
tenant_id: UUID = Path(..., description="UUID of the tenant to retrieve"),
|
|
177
|
+
) -> TenantResponse:
|
|
178
|
+
"""
|
|
179
|
+
Get a specific tenant by ID.
|
|
180
|
+
|
|
181
|
+
This endpoint retrieves detailed information about a tenant including
|
|
182
|
+
its settings, creation date, and other metadata.
|
|
183
|
+
"""
|
|
184
|
+
logger.info("Retrieving tenant", tenant_id=str(tenant_id))
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
async with get_async_session() as db:
|
|
188
|
+
# Query for the tenant
|
|
189
|
+
stmt = select(Tenants).where(Tenants.id == tenant_id)
|
|
190
|
+
result = await db.execute(stmt)
|
|
191
|
+
tenant = result.scalar_one_or_none()
|
|
192
|
+
|
|
193
|
+
if not tenant:
|
|
194
|
+
raise HTTPException(status_code=404, detail=f"Tenant with ID {tenant_id} not found")
|
|
195
|
+
|
|
196
|
+
logger.info(
|
|
197
|
+
"Tenant retrieved successfully",
|
|
198
|
+
tenant_id=str(tenant_id),
|
|
199
|
+
slug=tenant.slug,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return TenantResponse(
|
|
203
|
+
tenant_id=str(tenant.id),
|
|
204
|
+
name=tenant.name,
|
|
205
|
+
slug=tenant.slug,
|
|
206
|
+
settings=tenant.settings or {},
|
|
207
|
+
created_at=tenant.created_at.isoformat() if tenant.created_at else "",
|
|
208
|
+
updated_at=tenant.updated_at.isoformat() if tenant.updated_at else "",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
except HTTPException:
|
|
212
|
+
# Re-raise HTTP exceptions as-is
|
|
213
|
+
raise
|
|
214
|
+
except Exception as e:
|
|
215
|
+
logger.error("Failed to retrieve tenant", tenant_id=str(tenant_id), error=str(e))
|
|
216
|
+
raise HTTPException(status_code=500, detail=f"Failed to retrieve tenant: {str(e)}") from e
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@router.get("/tenants", response_model=TenantListResponse)
|
|
220
|
+
async def list_tenants() -> TenantListResponse:
|
|
221
|
+
"""
|
|
222
|
+
List all tenants in the system.
|
|
223
|
+
|
|
224
|
+
This endpoint is useful for:
|
|
225
|
+
- Multi-tenant administration
|
|
226
|
+
- Tenant discovery and management
|
|
227
|
+
- System overview and monitoring
|
|
228
|
+
"""
|
|
229
|
+
logger.info("Listing all tenants")
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
async with get_async_session() as db:
|
|
233
|
+
# Query for all tenants, ordered by creation date
|
|
234
|
+
stmt = select(Tenants).order_by(Tenants.created_at.desc())
|
|
235
|
+
result = await db.execute(stmt)
|
|
236
|
+
tenants = result.scalars().all()
|
|
237
|
+
|
|
238
|
+
tenant_responses = [
|
|
239
|
+
TenantResponse(
|
|
240
|
+
tenant_id=str(tenant.id),
|
|
241
|
+
name=tenant.name,
|
|
242
|
+
slug=tenant.slug,
|
|
243
|
+
settings=tenant.settings or {},
|
|
244
|
+
created_at=(tenant.created_at.isoformat() if tenant.created_at else ""),
|
|
245
|
+
updated_at=(tenant.updated_at.isoformat() if tenant.updated_at else ""),
|
|
246
|
+
)
|
|
247
|
+
for tenant in tenants
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
logger.info(
|
|
251
|
+
"Tenants listed successfully",
|
|
252
|
+
total_count=len(tenant_responses),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return TenantListResponse(
|
|
256
|
+
tenants=tenant_responses,
|
|
257
|
+
total_count=len(tenant_responses),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
except Exception as e:
|
|
261
|
+
logger.error("Failed to list tenants", error=str(e))
|
|
262
|
+
raise HTTPException(status_code=500, detail=f"Failed to list tenants: {str(e)}") from e
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@router.put("/tenant/{tenant_id}", response_model=TenantResponse)
|
|
266
|
+
async def update_tenant(
|
|
267
|
+
request: TenantUpdateRequest,
|
|
268
|
+
tenant_id: UUID = Path(..., description="UUID of the tenant to update"),
|
|
269
|
+
) -> TenantResponse:
|
|
270
|
+
"""
|
|
271
|
+
Update a specific tenant.
|
|
272
|
+
|
|
273
|
+
This endpoint allows updating tenant information including:
|
|
274
|
+
- Display name
|
|
275
|
+
- Slug (URL identifier)
|
|
276
|
+
- Settings (JSON metadata)
|
|
277
|
+
|
|
278
|
+
Only provided fields will be updated; others remain unchanged.
|
|
279
|
+
"""
|
|
280
|
+
logger.info(
|
|
281
|
+
"Updating tenant",
|
|
282
|
+
tenant_id=str(tenant_id),
|
|
283
|
+
name=request.name,
|
|
284
|
+
slug=request.slug,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
async with get_async_session() as db:
|
|
289
|
+
# First, check if tenant exists
|
|
290
|
+
stmt = select(Tenants).where(Tenants.id == tenant_id)
|
|
291
|
+
result = await db.execute(stmt)
|
|
292
|
+
existing_tenant = result.scalar_one_or_none()
|
|
293
|
+
|
|
294
|
+
if not existing_tenant:
|
|
295
|
+
raise HTTPException(status_code=404, detail=f"Tenant with ID {tenant_id} not found")
|
|
296
|
+
|
|
297
|
+
# Check for slug conflicts if slug is being updated
|
|
298
|
+
if request.slug and request.slug != existing_tenant.slug:
|
|
299
|
+
slug_check = select(Tenants).where(
|
|
300
|
+
(Tenants.slug == request.slug) & (Tenants.id != tenant_id)
|
|
301
|
+
)
|
|
302
|
+
result = await db.execute(slug_check)
|
|
303
|
+
if result.scalar_one_or_none():
|
|
304
|
+
raise HTTPException(
|
|
305
|
+
status_code=409,
|
|
306
|
+
detail=f"A tenant with slug '{request.slug}' already exists",
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Build update data - only include provided fields
|
|
310
|
+
update_data = {}
|
|
311
|
+
if request.name is not None:
|
|
312
|
+
update_data["name"] = request.name
|
|
313
|
+
if request.slug is not None:
|
|
314
|
+
update_data["slug"] = request.slug
|
|
315
|
+
if request.settings is not None:
|
|
316
|
+
update_data["settings"] = request.settings
|
|
317
|
+
|
|
318
|
+
# Add updated_at timestamp
|
|
319
|
+
from datetime import datetime
|
|
320
|
+
|
|
321
|
+
update_data["updated_at"] = datetime.now(UTC)
|
|
322
|
+
|
|
323
|
+
if update_data:
|
|
324
|
+
# Perform the update
|
|
325
|
+
stmt = update(Tenants).where(Tenants.id == tenant_id).values(**update_data)
|
|
326
|
+
await db.execute(stmt)
|
|
327
|
+
await db.commit()
|
|
328
|
+
|
|
329
|
+
# Fetch the updated tenant
|
|
330
|
+
stmt = select(Tenants).where(Tenants.id == tenant_id)
|
|
331
|
+
result = await db.execute(stmt)
|
|
332
|
+
updated_tenant = result.scalar_one()
|
|
333
|
+
|
|
334
|
+
logger.info(
|
|
335
|
+
"Tenant updated successfully",
|
|
336
|
+
tenant_id=str(tenant_id),
|
|
337
|
+
name=updated_tenant.name,
|
|
338
|
+
slug=updated_tenant.slug,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
return TenantResponse(
|
|
342
|
+
tenant_id=str(updated_tenant.id),
|
|
343
|
+
name=updated_tenant.name,
|
|
344
|
+
slug=updated_tenant.slug,
|
|
345
|
+
settings=updated_tenant.settings or {},
|
|
346
|
+
created_at=(
|
|
347
|
+
updated_tenant.created_at.isoformat() if updated_tenant.created_at else ""
|
|
348
|
+
),
|
|
349
|
+
updated_at=(
|
|
350
|
+
updated_tenant.updated_at.isoformat() if updated_tenant.updated_at else ""
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
except HTTPException:
|
|
355
|
+
# Re-raise HTTP exceptions as-is
|
|
356
|
+
raise
|
|
357
|
+
except Exception as e:
|
|
358
|
+
logger.error("Failed to update tenant", tenant_id=str(tenant_id), error=str(e))
|
|
359
|
+
raise HTTPException(status_code=500, detail=f"Failed to update tenant: {str(e)}") from e
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@router.delete("/tenant/{tenant_id}")
|
|
363
|
+
async def delete_tenant(
|
|
364
|
+
tenant_id: UUID = Path(..., description="UUID of the tenant to delete"),
|
|
365
|
+
) -> dict[str, Any]:
|
|
366
|
+
"""
|
|
367
|
+
Delete a specific tenant.
|
|
368
|
+
|
|
369
|
+
**WARNING**: This operation is destructive and will cascade delete all related data:
|
|
370
|
+
- All users in this tenant
|
|
371
|
+
- All boards and their content
|
|
372
|
+
- All generations and media
|
|
373
|
+
- All provider configurations
|
|
374
|
+
- All credit transactions
|
|
375
|
+
|
|
376
|
+
This operation cannot be undone. Use with extreme caution.
|
|
377
|
+
"""
|
|
378
|
+
logger.warning(
|
|
379
|
+
"Attempting to delete tenant - this is a destructive operation",
|
|
380
|
+
tenant_id=str(tenant_id),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
async with get_async_session() as db:
|
|
385
|
+
# First, check if tenant exists and get its info for logging
|
|
386
|
+
stmt = select(Tenants).where(Tenants.id == tenant_id)
|
|
387
|
+
result = await db.execute(stmt)
|
|
388
|
+
existing_tenant = result.scalar_one_or_none()
|
|
389
|
+
|
|
390
|
+
if not existing_tenant:
|
|
391
|
+
raise HTTPException(status_code=404, detail=f"Tenant with ID {tenant_id} not found")
|
|
392
|
+
|
|
393
|
+
# Prevent deletion of default tenant in single-tenant mode
|
|
394
|
+
if (
|
|
395
|
+
not settings.multi_tenant_mode
|
|
396
|
+
and existing_tenant.slug == settings.default_tenant_slug
|
|
397
|
+
):
|
|
398
|
+
raise HTTPException(
|
|
399
|
+
status_code=400,
|
|
400
|
+
detail="Cannot delete the default tenant in single-tenant mode. "
|
|
401
|
+
"Enable multi-tenant mode or use a different default tenant first.",
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
tenant_name = existing_tenant.name
|
|
405
|
+
tenant_slug = existing_tenant.slug
|
|
406
|
+
|
|
407
|
+
# Perform the deletion (CASCADE will handle related records)
|
|
408
|
+
stmt = delete(Tenants).where(Tenants.id == tenant_id)
|
|
409
|
+
result = await db.execute(stmt)
|
|
410
|
+
await db.commit()
|
|
411
|
+
|
|
412
|
+
if result.rowcount == 0:
|
|
413
|
+
# This shouldn't happen since we checked existence above
|
|
414
|
+
raise HTTPException(status_code=404, detail=f"Tenant with ID {tenant_id} not found")
|
|
415
|
+
|
|
416
|
+
logger.warning(
|
|
417
|
+
"Tenant deleted successfully - all related data has been removed",
|
|
418
|
+
tenant_id=str(tenant_id),
|
|
419
|
+
name=tenant_name,
|
|
420
|
+
slug=tenant_slug,
|
|
421
|
+
deleted_records=result.rowcount,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
"message": f"Tenant '{tenant_name}' ({tenant_slug}) deleted successfully",
|
|
426
|
+
"tenant_id": str(tenant_id),
|
|
427
|
+
"warning": "All related data (users, boards, generations, etc.) has been permanently deleted", # noqa: E501
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
except HTTPException:
|
|
431
|
+
# Re-raise HTTP exceptions as-is
|
|
432
|
+
raise
|
|
433
|
+
except Exception as e:
|
|
434
|
+
logger.error("Failed to delete tenant", tenant_id=str(tenant_id), error=str(e))
|
|
435
|
+
raise HTTPException(status_code=500, detail=f"Failed to delete tenant: {str(e)}") from e
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@router.get("/status")
|
|
439
|
+
async def setup_status() -> dict[str, Any]:
|
|
440
|
+
"""
|
|
441
|
+
Get the current setup status of the application.
|
|
442
|
+
|
|
443
|
+
This endpoint provides information about:
|
|
444
|
+
- Whether a default tenant exists
|
|
445
|
+
- Current configuration mode
|
|
446
|
+
- Setup recommendations
|
|
447
|
+
"""
|
|
448
|
+
try:
|
|
449
|
+
async with get_async_session() as db:
|
|
450
|
+
# Check if default tenant exists
|
|
451
|
+
try:
|
|
452
|
+
default_tenant_id = await ensure_tenant(db, slug=settings.default_tenant_slug)
|
|
453
|
+
has_default_tenant = True
|
|
454
|
+
default_tenant_uuid = str(default_tenant_id)
|
|
455
|
+
except Exception:
|
|
456
|
+
has_default_tenant = False
|
|
457
|
+
default_tenant_uuid = None
|
|
458
|
+
|
|
459
|
+
setup_needed = not has_default_tenant and not settings.multi_tenant_mode
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
"setup_needed": setup_needed,
|
|
463
|
+
"has_default_tenant": has_default_tenant,
|
|
464
|
+
"default_tenant_id": default_tenant_uuid,
|
|
465
|
+
"default_tenant_slug": settings.default_tenant_slug,
|
|
466
|
+
"multi_tenant_mode": settings.multi_tenant_mode,
|
|
467
|
+
"auth_provider": settings.auth_provider,
|
|
468
|
+
"recommendations": _get_setup_recommendations(
|
|
469
|
+
setup_needed, has_default_tenant, settings.multi_tenant_mode
|
|
470
|
+
),
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
except Exception as e:
|
|
474
|
+
logger.error("Failed to get setup status", error=str(e))
|
|
475
|
+
raise HTTPException(status_code=500, detail=f"Failed to get setup status: {str(e)}") from e
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _get_setup_recommendations(
|
|
479
|
+
setup_needed: bool, has_default_tenant: bool, multi_tenant_mode: bool
|
|
480
|
+
) -> list[str]:
|
|
481
|
+
"""Get setup recommendations based on current state."""
|
|
482
|
+
recommendations = []
|
|
483
|
+
|
|
484
|
+
if setup_needed:
|
|
485
|
+
recommendations.append(
|
|
486
|
+
f"Create a default tenant using POST /api/setup/tenant with slug '{settings.default_tenant_slug}'" # noqa: E501
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
if not has_default_tenant and not multi_tenant_mode:
|
|
490
|
+
recommendations.append(
|
|
491
|
+
"Run database migrations to create the default tenant: 'alembic upgrade head'"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
if settings.auth_provider == "none" and not multi_tenant_mode:
|
|
495
|
+
recommendations.append(
|
|
496
|
+
"Consider configuring a proper authentication provider for production use"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
if not recommendations:
|
|
500
|
+
if multi_tenant_mode:
|
|
501
|
+
recommendations.append("System is ready for multi-tenant operation")
|
|
502
|
+
else:
|
|
503
|
+
recommendations.append("Single-tenant setup is complete and ready to use")
|
|
504
|
+
|
|
505
|
+
return recommendations
|