@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,889 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
import strawberry
|
|
7
|
+
from sqlalchemy import or_, select
|
|
8
|
+
from sqlalchemy.orm import selectinload
|
|
9
|
+
|
|
10
|
+
from ...database.connection import get_async_session
|
|
11
|
+
from ...dbmodels import BoardMembers, Boards, Generations, Users
|
|
12
|
+
from ...generators.registry import registry as generator_registry
|
|
13
|
+
from ...jobs import repository as jobs_repo
|
|
14
|
+
from ...logging import get_logger
|
|
15
|
+
from ...workers.actors import process_generation
|
|
16
|
+
from ..access_control import can_access_board, get_auth_context_from_info
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ..mutations.root import CreateGenerationInput
|
|
20
|
+
from ..types.board import Board
|
|
21
|
+
from ..types.generation import ArtifactType, Generation, GenerationStatus
|
|
22
|
+
from ..types.user import User
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Query resolvers
|
|
28
|
+
async def resolve_generation_by_id(info: strawberry.Info, id: UUID) -> Generation | None:
|
|
29
|
+
"""
|
|
30
|
+
Resolve a generation by its ID.
|
|
31
|
+
|
|
32
|
+
Checks authorization: user must have access to the generation's board.
|
|
33
|
+
"""
|
|
34
|
+
auth_context = await get_auth_context_from_info(info)
|
|
35
|
+
if auth_context is None:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
async with get_async_session() as session:
|
|
39
|
+
# Query generation
|
|
40
|
+
stmt = select(Generations).where(Generations.id == id)
|
|
41
|
+
result = await session.execute(stmt)
|
|
42
|
+
gen = result.scalar_one_or_none()
|
|
43
|
+
|
|
44
|
+
if not gen:
|
|
45
|
+
logger.info("Generation not found", generation_id=str(id))
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
# Check board access
|
|
49
|
+
board_stmt = (
|
|
50
|
+
select(Boards)
|
|
51
|
+
.where(Boards.id == gen.board_id)
|
|
52
|
+
.options(selectinload(Boards.board_members))
|
|
53
|
+
)
|
|
54
|
+
board_result = await session.execute(board_stmt)
|
|
55
|
+
board = board_result.scalar_one_or_none()
|
|
56
|
+
|
|
57
|
+
if not board or not can_access_board(board, auth_context):
|
|
58
|
+
logger.info(
|
|
59
|
+
"Access denied to generation",
|
|
60
|
+
generation_id=str(id),
|
|
61
|
+
board_id=str(gen.board_id),
|
|
62
|
+
user_id=(
|
|
63
|
+
str(auth_context.user_id) if auth_context and auth_context.user_id else None
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
# Convert to GraphQL type
|
|
69
|
+
from ..types.generation import ArtifactType, GenerationStatus
|
|
70
|
+
from ..types.generation import Generation as GenerationType
|
|
71
|
+
|
|
72
|
+
return GenerationType(
|
|
73
|
+
id=gen.id,
|
|
74
|
+
tenant_id=gen.tenant_id,
|
|
75
|
+
board_id=gen.board_id,
|
|
76
|
+
user_id=gen.user_id,
|
|
77
|
+
generator_name=gen.generator_name,
|
|
78
|
+
artifact_type=ArtifactType(gen.artifact_type),
|
|
79
|
+
storage_url=gen.storage_url,
|
|
80
|
+
thumbnail_url=gen.thumbnail_url,
|
|
81
|
+
additional_files=gen.additional_files or [],
|
|
82
|
+
input_params=gen.input_params or {},
|
|
83
|
+
output_metadata=gen.output_metadata or {},
|
|
84
|
+
parent_generation_id=gen.parent_generation_id,
|
|
85
|
+
input_generation_ids=gen.input_generation_ids or [],
|
|
86
|
+
external_job_id=gen.external_job_id,
|
|
87
|
+
status=GenerationStatus(gen.status),
|
|
88
|
+
progress=float(gen.progress or 0.0),
|
|
89
|
+
error_message=gen.error_message,
|
|
90
|
+
started_at=gen.started_at,
|
|
91
|
+
completed_at=gen.completed_at,
|
|
92
|
+
created_at=gen.created_at,
|
|
93
|
+
updated_at=gen.updated_at,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def resolve_recent_generations(
|
|
98
|
+
info: strawberry.Info,
|
|
99
|
+
board_id: UUID | None,
|
|
100
|
+
status: GenerationStatus | None,
|
|
101
|
+
artifact_type: ArtifactType | None,
|
|
102
|
+
limit: int,
|
|
103
|
+
offset: int,
|
|
104
|
+
) -> list[Generation]:
|
|
105
|
+
"""
|
|
106
|
+
Resolve recent generations with filtering.
|
|
107
|
+
|
|
108
|
+
If board_id is None, returns generations from all boards the user has access to.
|
|
109
|
+
"""
|
|
110
|
+
auth_context = await get_auth_context_from_info(info)
|
|
111
|
+
if not auth_context or not auth_context.is_authenticated:
|
|
112
|
+
logger.info("Unauthenticated access to recent_generations")
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
async with get_async_session() as session:
|
|
116
|
+
# Build base query
|
|
117
|
+
generations_query = select(Generations)
|
|
118
|
+
|
|
119
|
+
# Apply filters
|
|
120
|
+
if board_id is not None:
|
|
121
|
+
# Check access to specific board
|
|
122
|
+
board_stmt = (
|
|
123
|
+
select(Boards)
|
|
124
|
+
.where(Boards.id == board_id)
|
|
125
|
+
.options(selectinload(Boards.board_members))
|
|
126
|
+
)
|
|
127
|
+
board_result = await session.execute(board_stmt)
|
|
128
|
+
board = board_result.scalar_one_or_none()
|
|
129
|
+
|
|
130
|
+
if not board or not can_access_board(board, auth_context):
|
|
131
|
+
logger.info(
|
|
132
|
+
"Access denied to board for recent generations",
|
|
133
|
+
board_id=str(board_id),
|
|
134
|
+
user_id=str(auth_context.user_id),
|
|
135
|
+
)
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
generations_query = generations_query.where(Generations.board_id == board_id)
|
|
139
|
+
else:
|
|
140
|
+
# Get all boards user has access to
|
|
141
|
+
member_board_ids = select(BoardMembers.board_id).where(
|
|
142
|
+
BoardMembers.user_id == auth_context.user_id
|
|
143
|
+
)
|
|
144
|
+
accessible_boards_condition = or_(
|
|
145
|
+
Boards.owner_id == auth_context.user_id,
|
|
146
|
+
Boards.id.in_(member_board_ids),
|
|
147
|
+
Boards.is_public,
|
|
148
|
+
)
|
|
149
|
+
accessible_boards_stmt = select(Boards.id).where(accessible_boards_condition)
|
|
150
|
+
accessible_boards_result = await session.execute(accessible_boards_stmt)
|
|
151
|
+
accessible_board_ids = [row[0] for row in accessible_boards_result.all()]
|
|
152
|
+
|
|
153
|
+
if not accessible_board_ids:
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
generations_query = generations_query.where(
|
|
157
|
+
Generations.board_id.in_(accessible_board_ids)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Apply status filter
|
|
161
|
+
if status is not None:
|
|
162
|
+
generations_query = generations_query.where(Generations.status == status.value)
|
|
163
|
+
|
|
164
|
+
# Apply artifact_type filter
|
|
165
|
+
if artifact_type is not None:
|
|
166
|
+
generations_query = generations_query.where(
|
|
167
|
+
Generations.artifact_type == artifact_type.value
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Order by created_at DESC and apply pagination
|
|
171
|
+
generations_query = (
|
|
172
|
+
generations_query.order_by(Generations.created_at.desc()).limit(limit).offset(offset)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
result = await session.execute(generations_query)
|
|
176
|
+
generations = result.scalars().all()
|
|
177
|
+
|
|
178
|
+
# Convert to GraphQL types
|
|
179
|
+
from ..types.generation import ArtifactType as ArtifactTypeEnum
|
|
180
|
+
from ..types.generation import Generation as GenerationType
|
|
181
|
+
from ..types.generation import GenerationStatus as GenerationStatusEnum
|
|
182
|
+
|
|
183
|
+
return [
|
|
184
|
+
GenerationType(
|
|
185
|
+
id=gen.id,
|
|
186
|
+
tenant_id=gen.tenant_id,
|
|
187
|
+
board_id=gen.board_id,
|
|
188
|
+
user_id=gen.user_id,
|
|
189
|
+
generator_name=gen.generator_name,
|
|
190
|
+
artifact_type=ArtifactTypeEnum(gen.artifact_type),
|
|
191
|
+
storage_url=gen.storage_url,
|
|
192
|
+
thumbnail_url=gen.thumbnail_url,
|
|
193
|
+
additional_files=gen.additional_files or [],
|
|
194
|
+
input_params=gen.input_params or {},
|
|
195
|
+
output_metadata=gen.output_metadata or {},
|
|
196
|
+
parent_generation_id=gen.parent_generation_id,
|
|
197
|
+
input_generation_ids=gen.input_generation_ids or [],
|
|
198
|
+
external_job_id=gen.external_job_id,
|
|
199
|
+
status=GenerationStatusEnum(gen.status),
|
|
200
|
+
progress=float(gen.progress or 0.0),
|
|
201
|
+
error_message=gen.error_message,
|
|
202
|
+
started_at=gen.started_at,
|
|
203
|
+
completed_at=gen.completed_at,
|
|
204
|
+
created_at=gen.created_at,
|
|
205
|
+
updated_at=gen.updated_at,
|
|
206
|
+
)
|
|
207
|
+
for gen in generations
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# Field resolvers
|
|
212
|
+
async def resolve_generation_board(generation: Generation, info: strawberry.Info) -> Board:
|
|
213
|
+
"""Resolve the board this generation belongs to."""
|
|
214
|
+
auth_context = await get_auth_context_from_info(info)
|
|
215
|
+
|
|
216
|
+
async with get_async_session() as session:
|
|
217
|
+
stmt = (
|
|
218
|
+
select(Boards)
|
|
219
|
+
.where(Boards.id == generation.board_id)
|
|
220
|
+
.options(
|
|
221
|
+
selectinload(Boards.owner),
|
|
222
|
+
selectinload(Boards.board_members).selectinload(BoardMembers.user),
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
result = await session.execute(stmt)
|
|
226
|
+
board = result.scalar_one_or_none()
|
|
227
|
+
|
|
228
|
+
if not board:
|
|
229
|
+
raise RuntimeError("Generation board not found")
|
|
230
|
+
|
|
231
|
+
if not can_access_board(board, auth_context):
|
|
232
|
+
raise RuntimeError("Access denied to generation board")
|
|
233
|
+
|
|
234
|
+
from ..types.board import Board as BoardType
|
|
235
|
+
|
|
236
|
+
return BoardType(
|
|
237
|
+
id=board.id,
|
|
238
|
+
tenant_id=board.tenant_id,
|
|
239
|
+
owner_id=board.owner_id,
|
|
240
|
+
title=board.title,
|
|
241
|
+
description=board.description,
|
|
242
|
+
is_public=board.is_public,
|
|
243
|
+
settings=board.settings or {},
|
|
244
|
+
metadata=board.metadata_ or {},
|
|
245
|
+
created_at=board.created_at,
|
|
246
|
+
updated_at=board.updated_at,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def resolve_generation_user(generation: Generation, info: strawberry.Info) -> User:
|
|
251
|
+
"""Resolve the user who created this generation."""
|
|
252
|
+
async with get_async_session() as session:
|
|
253
|
+
stmt = select(Users).where(Users.id == generation.user_id)
|
|
254
|
+
result = await session.execute(stmt)
|
|
255
|
+
user = result.scalar_one_or_none()
|
|
256
|
+
|
|
257
|
+
if not user:
|
|
258
|
+
raise RuntimeError("Generation user not found")
|
|
259
|
+
|
|
260
|
+
from ..types.user import User as UserType
|
|
261
|
+
|
|
262
|
+
return UserType(
|
|
263
|
+
id=user.id,
|
|
264
|
+
tenant_id=user.tenant_id,
|
|
265
|
+
auth_provider=user.auth_provider,
|
|
266
|
+
auth_subject=user.auth_subject,
|
|
267
|
+
email=user.email,
|
|
268
|
+
display_name=user.display_name,
|
|
269
|
+
avatar_url=user.avatar_url,
|
|
270
|
+
created_at=user.created_at,
|
|
271
|
+
updated_at=user.updated_at,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def resolve_generation_parent(
|
|
276
|
+
generation: Generation, info: strawberry.Info
|
|
277
|
+
) -> Generation | None:
|
|
278
|
+
"""Resolve the parent generation if any."""
|
|
279
|
+
if not generation.parent_generation_id:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
auth_context = await get_auth_context_from_info(info)
|
|
283
|
+
|
|
284
|
+
async with get_async_session() as session:
|
|
285
|
+
# Query parent generation
|
|
286
|
+
stmt = select(Generations).where(Generations.id == generation.parent_generation_id)
|
|
287
|
+
result = await session.execute(stmt)
|
|
288
|
+
parent = result.scalar_one_or_none()
|
|
289
|
+
|
|
290
|
+
if not parent:
|
|
291
|
+
logger.warning(
|
|
292
|
+
"Parent generation not found",
|
|
293
|
+
parent_id=str(generation.parent_generation_id),
|
|
294
|
+
)
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
# Check access to parent's board
|
|
298
|
+
board_stmt = (
|
|
299
|
+
select(Boards)
|
|
300
|
+
.where(Boards.id == parent.board_id)
|
|
301
|
+
.options(selectinload(Boards.board_members))
|
|
302
|
+
)
|
|
303
|
+
board_result = await session.execute(board_stmt)
|
|
304
|
+
board = board_result.scalar_one_or_none()
|
|
305
|
+
|
|
306
|
+
if not board or not can_access_board(board, auth_context):
|
|
307
|
+
logger.info(
|
|
308
|
+
"Access denied to parent generation",
|
|
309
|
+
parent_id=str(generation.parent_generation_id),
|
|
310
|
+
)
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
# Convert to GraphQL type
|
|
314
|
+
from ..types.generation import ArtifactType, GenerationStatus
|
|
315
|
+
from ..types.generation import Generation as GenerationType
|
|
316
|
+
|
|
317
|
+
return GenerationType(
|
|
318
|
+
id=parent.id,
|
|
319
|
+
tenant_id=parent.tenant_id,
|
|
320
|
+
board_id=parent.board_id,
|
|
321
|
+
user_id=parent.user_id,
|
|
322
|
+
generator_name=parent.generator_name,
|
|
323
|
+
artifact_type=ArtifactType(parent.artifact_type),
|
|
324
|
+
storage_url=parent.storage_url,
|
|
325
|
+
thumbnail_url=parent.thumbnail_url,
|
|
326
|
+
additional_files=parent.additional_files or [],
|
|
327
|
+
input_params=parent.input_params or {},
|
|
328
|
+
output_metadata=parent.output_metadata or {},
|
|
329
|
+
parent_generation_id=parent.parent_generation_id,
|
|
330
|
+
input_generation_ids=parent.input_generation_ids or [],
|
|
331
|
+
external_job_id=parent.external_job_id,
|
|
332
|
+
status=GenerationStatus(parent.status),
|
|
333
|
+
progress=float(parent.progress or 0.0),
|
|
334
|
+
error_message=parent.error_message,
|
|
335
|
+
started_at=parent.started_at,
|
|
336
|
+
completed_at=parent.completed_at,
|
|
337
|
+
created_at=parent.created_at,
|
|
338
|
+
updated_at=parent.updated_at,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
async def resolve_generation_inputs(
|
|
343
|
+
generation: Generation, info: strawberry.Info
|
|
344
|
+
) -> list[Generation]: # noqa: E501
|
|
345
|
+
"""Resolve input generations used for this generation."""
|
|
346
|
+
if not generation.input_generation_ids:
|
|
347
|
+
return []
|
|
348
|
+
|
|
349
|
+
auth_context = await get_auth_context_from_info(info)
|
|
350
|
+
|
|
351
|
+
async with get_async_session() as session:
|
|
352
|
+
# Query input generations
|
|
353
|
+
stmt = select(Generations).where(Generations.id.in_(generation.input_generation_ids))
|
|
354
|
+
result = await session.execute(stmt)
|
|
355
|
+
inputs = result.scalars().all()
|
|
356
|
+
|
|
357
|
+
# Filter by board access
|
|
358
|
+
accessible_inputs = []
|
|
359
|
+
for input_gen in inputs:
|
|
360
|
+
board_stmt = (
|
|
361
|
+
select(Boards)
|
|
362
|
+
.where(Boards.id == input_gen.board_id)
|
|
363
|
+
.options(selectinload(Boards.board_members))
|
|
364
|
+
)
|
|
365
|
+
board_result = await session.execute(board_stmt)
|
|
366
|
+
board = board_result.scalar_one_or_none()
|
|
367
|
+
|
|
368
|
+
if board and can_access_board(board, auth_context):
|
|
369
|
+
accessible_inputs.append(input_gen)
|
|
370
|
+
|
|
371
|
+
# Convert to GraphQL types
|
|
372
|
+
from ..types.generation import ArtifactType, GenerationStatus
|
|
373
|
+
from ..types.generation import Generation as GenerationType
|
|
374
|
+
|
|
375
|
+
return [
|
|
376
|
+
GenerationType(
|
|
377
|
+
id=gen.id,
|
|
378
|
+
tenant_id=gen.tenant_id,
|
|
379
|
+
board_id=gen.board_id,
|
|
380
|
+
user_id=gen.user_id,
|
|
381
|
+
generator_name=gen.generator_name,
|
|
382
|
+
artifact_type=ArtifactType(gen.artifact_type),
|
|
383
|
+
storage_url=gen.storage_url,
|
|
384
|
+
thumbnail_url=gen.thumbnail_url,
|
|
385
|
+
additional_files=gen.additional_files or [],
|
|
386
|
+
input_params=gen.input_params or {},
|
|
387
|
+
output_metadata=gen.output_metadata or {},
|
|
388
|
+
parent_generation_id=gen.parent_generation_id,
|
|
389
|
+
input_generation_ids=gen.input_generation_ids or [],
|
|
390
|
+
external_job_id=gen.external_job_id,
|
|
391
|
+
status=GenerationStatus(gen.status),
|
|
392
|
+
progress=float(gen.progress or 0.0),
|
|
393
|
+
error_message=gen.error_message,
|
|
394
|
+
started_at=gen.started_at,
|
|
395
|
+
completed_at=gen.completed_at,
|
|
396
|
+
created_at=gen.created_at,
|
|
397
|
+
updated_at=gen.updated_at,
|
|
398
|
+
)
|
|
399
|
+
for gen in accessible_inputs
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
async def resolve_generation_children(
|
|
404
|
+
generation: Generation, info: strawberry.Info
|
|
405
|
+
) -> list[Generation]: # noqa: E501
|
|
406
|
+
"""Resolve child generations derived from this one."""
|
|
407
|
+
auth_context = await get_auth_context_from_info(info)
|
|
408
|
+
|
|
409
|
+
async with get_async_session() as session:
|
|
410
|
+
# Query child generations
|
|
411
|
+
stmt = select(Generations).where(Generations.parent_generation_id == generation.id)
|
|
412
|
+
result = await session.execute(stmt)
|
|
413
|
+
children = result.scalars().all()
|
|
414
|
+
|
|
415
|
+
# Filter by board access
|
|
416
|
+
accessible_children = []
|
|
417
|
+
for child_gen in children:
|
|
418
|
+
board_stmt = (
|
|
419
|
+
select(Boards)
|
|
420
|
+
.where(Boards.id == child_gen.board_id)
|
|
421
|
+
.options(selectinload(Boards.board_members))
|
|
422
|
+
)
|
|
423
|
+
board_result = await session.execute(board_stmt)
|
|
424
|
+
board = board_result.scalar_one_or_none()
|
|
425
|
+
|
|
426
|
+
if board and can_access_board(board, auth_context):
|
|
427
|
+
accessible_children.append(child_gen)
|
|
428
|
+
|
|
429
|
+
# Convert to GraphQL types
|
|
430
|
+
from ..types.generation import ArtifactType, GenerationStatus
|
|
431
|
+
from ..types.generation import Generation as GenerationType
|
|
432
|
+
|
|
433
|
+
return [
|
|
434
|
+
GenerationType(
|
|
435
|
+
id=gen.id,
|
|
436
|
+
tenant_id=gen.tenant_id,
|
|
437
|
+
board_id=gen.board_id,
|
|
438
|
+
user_id=gen.user_id,
|
|
439
|
+
generator_name=gen.generator_name,
|
|
440
|
+
artifact_type=ArtifactType(gen.artifact_type),
|
|
441
|
+
storage_url=gen.storage_url,
|
|
442
|
+
thumbnail_url=gen.thumbnail_url,
|
|
443
|
+
additional_files=gen.additional_files or [],
|
|
444
|
+
input_params=gen.input_params or {},
|
|
445
|
+
output_metadata=gen.output_metadata or {},
|
|
446
|
+
parent_generation_id=gen.parent_generation_id,
|
|
447
|
+
input_generation_ids=gen.input_generation_ids or [],
|
|
448
|
+
external_job_id=gen.external_job_id,
|
|
449
|
+
status=GenerationStatus(gen.status),
|
|
450
|
+
progress=float(gen.progress or 0.0),
|
|
451
|
+
error_message=gen.error_message,
|
|
452
|
+
started_at=gen.started_at,
|
|
453
|
+
completed_at=gen.completed_at,
|
|
454
|
+
created_at=gen.created_at,
|
|
455
|
+
updated_at=gen.updated_at,
|
|
456
|
+
)
|
|
457
|
+
for gen in accessible_children
|
|
458
|
+
]
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# Mutation resolvers
|
|
462
|
+
async def create_generation(info: strawberry.Info, input: CreateGenerationInput) -> Generation:
|
|
463
|
+
"""
|
|
464
|
+
Create a new generation and enqueue it for processing.
|
|
465
|
+
|
|
466
|
+
Requires editor or owner role on the target board.
|
|
467
|
+
"""
|
|
468
|
+
auth_context = await get_auth_context_from_info(info)
|
|
469
|
+
if not auth_context or not auth_context.is_authenticated or not auth_context.user_id:
|
|
470
|
+
raise RuntimeError("Authentication required to create a generation")
|
|
471
|
+
|
|
472
|
+
async with get_async_session() as session:
|
|
473
|
+
# Check board access - require editor or owner role
|
|
474
|
+
board_stmt = (
|
|
475
|
+
select(Boards)
|
|
476
|
+
.where(Boards.id == input.board_id)
|
|
477
|
+
.options(selectinload(Boards.board_members))
|
|
478
|
+
)
|
|
479
|
+
board_result = await session.execute(board_stmt)
|
|
480
|
+
board = board_result.scalar_one_or_none()
|
|
481
|
+
|
|
482
|
+
if not board:
|
|
483
|
+
raise RuntimeError("Board not found")
|
|
484
|
+
|
|
485
|
+
# Check if user is owner or editor
|
|
486
|
+
is_owner = board.owner_id == auth_context.user_id
|
|
487
|
+
is_editor = any(
|
|
488
|
+
member.user_id == auth_context.user_id and member.role in {"editor", "admin"}
|
|
489
|
+
for member in board.board_members
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
if not is_owner and not is_editor:
|
|
493
|
+
raise RuntimeError(
|
|
494
|
+
"Permission denied: only board owner or editor can create generations"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# Validate generator exists
|
|
498
|
+
generator = generator_registry.get(input.generator_name)
|
|
499
|
+
if generator is None:
|
|
500
|
+
raise RuntimeError(f"Unknown generator: {input.generator_name}")
|
|
501
|
+
|
|
502
|
+
# Verify access to parent generation if provided
|
|
503
|
+
if input.parent_generation_id:
|
|
504
|
+
parent_stmt = select(Generations).where(Generations.id == input.parent_generation_id)
|
|
505
|
+
parent_result = await session.execute(parent_stmt)
|
|
506
|
+
parent_gen = parent_result.scalar_one_or_none()
|
|
507
|
+
|
|
508
|
+
if not parent_gen:
|
|
509
|
+
raise RuntimeError("Parent generation not found")
|
|
510
|
+
|
|
511
|
+
# Check access to parent's board
|
|
512
|
+
parent_board_stmt = (
|
|
513
|
+
select(Boards)
|
|
514
|
+
.where(Boards.id == parent_gen.board_id)
|
|
515
|
+
.options(selectinload(Boards.board_members))
|
|
516
|
+
)
|
|
517
|
+
parent_board_result = await session.execute(parent_board_stmt)
|
|
518
|
+
parent_board = parent_board_result.scalar_one_or_none()
|
|
519
|
+
|
|
520
|
+
if not parent_board or not can_access_board(parent_board, auth_context):
|
|
521
|
+
raise RuntimeError("Access denied to parent generation")
|
|
522
|
+
|
|
523
|
+
# Verify access to input generations if provided
|
|
524
|
+
if input.input_generation_ids:
|
|
525
|
+
for input_gen_id in input.input_generation_ids:
|
|
526
|
+
input_stmt = select(Generations).where(Generations.id == input_gen_id)
|
|
527
|
+
input_result = await session.execute(input_stmt)
|
|
528
|
+
input_gen = input_result.scalar_one_or_none()
|
|
529
|
+
|
|
530
|
+
if not input_gen:
|
|
531
|
+
raise RuntimeError(f"Input generation {input_gen_id} not found")
|
|
532
|
+
|
|
533
|
+
# Check access to input's board
|
|
534
|
+
input_board_stmt = (
|
|
535
|
+
select(Boards)
|
|
536
|
+
.where(Boards.id == input_gen.board_id)
|
|
537
|
+
.options(selectinload(Boards.board_members))
|
|
538
|
+
)
|
|
539
|
+
input_board_result = await session.execute(input_board_stmt)
|
|
540
|
+
input_board = input_board_result.scalar_one_or_none()
|
|
541
|
+
|
|
542
|
+
if not input_board or not can_access_board(input_board, auth_context):
|
|
543
|
+
raise RuntimeError(f"Access denied to input generation {input_gen_id}")
|
|
544
|
+
|
|
545
|
+
# Create generation record
|
|
546
|
+
gen = await jobs_repo.create_generation(
|
|
547
|
+
session,
|
|
548
|
+
tenant_id=auth_context.tenant_id,
|
|
549
|
+
board_id=input.board_id,
|
|
550
|
+
user_id=auth_context.user_id,
|
|
551
|
+
generator_name=input.generator_name,
|
|
552
|
+
artifact_type=input.artifact_type.value,
|
|
553
|
+
input_params=input.input_params,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# Update parent and input relationships if provided
|
|
557
|
+
if input.parent_generation_id:
|
|
558
|
+
gen.parent_generation_id = input.parent_generation_id
|
|
559
|
+
if input.input_generation_ids:
|
|
560
|
+
gen.input_generation_ids = input.input_generation_ids
|
|
561
|
+
|
|
562
|
+
await session.commit()
|
|
563
|
+
await session.refresh(gen)
|
|
564
|
+
|
|
565
|
+
logger.info(
|
|
566
|
+
"Generation created",
|
|
567
|
+
generation_id=str(gen.id),
|
|
568
|
+
board_id=str(input.board_id),
|
|
569
|
+
user_id=str(auth_context.user_id),
|
|
570
|
+
generator_name=input.generator_name,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
# Enqueue job for processing
|
|
574
|
+
message = process_generation.send(str(gen.id))
|
|
575
|
+
logger.info(
|
|
576
|
+
"Generation job enqueued",
|
|
577
|
+
generation_id=str(gen.id),
|
|
578
|
+
message_id=message.message_id,
|
|
579
|
+
queue_name=message.queue_name,
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# Convert to GraphQL type
|
|
583
|
+
from ..types.generation import ArtifactType, GenerationStatus
|
|
584
|
+
from ..types.generation import Generation as GenerationType
|
|
585
|
+
|
|
586
|
+
return GenerationType(
|
|
587
|
+
id=gen.id,
|
|
588
|
+
tenant_id=gen.tenant_id,
|
|
589
|
+
board_id=gen.board_id,
|
|
590
|
+
user_id=gen.user_id,
|
|
591
|
+
generator_name=gen.generator_name,
|
|
592
|
+
artifact_type=ArtifactType(gen.artifact_type),
|
|
593
|
+
storage_url=gen.storage_url,
|
|
594
|
+
thumbnail_url=gen.thumbnail_url,
|
|
595
|
+
additional_files=gen.additional_files or [],
|
|
596
|
+
input_params=gen.input_params or {},
|
|
597
|
+
output_metadata=gen.output_metadata or {},
|
|
598
|
+
parent_generation_id=gen.parent_generation_id,
|
|
599
|
+
input_generation_ids=gen.input_generation_ids or [],
|
|
600
|
+
external_job_id=gen.external_job_id,
|
|
601
|
+
status=GenerationStatus(gen.status),
|
|
602
|
+
progress=float(gen.progress or 0.0),
|
|
603
|
+
error_message=gen.error_message,
|
|
604
|
+
started_at=gen.started_at,
|
|
605
|
+
completed_at=gen.completed_at,
|
|
606
|
+
created_at=gen.created_at,
|
|
607
|
+
updated_at=gen.updated_at,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
async def cancel_generation(info: strawberry.Info, id: UUID) -> Generation:
|
|
612
|
+
"""
|
|
613
|
+
Cancel a pending or processing generation.
|
|
614
|
+
|
|
615
|
+
Requires ownership or editor role on the board.
|
|
616
|
+
"""
|
|
617
|
+
auth_context = await get_auth_context_from_info(info)
|
|
618
|
+
if not auth_context or not auth_context.is_authenticated:
|
|
619
|
+
raise RuntimeError("Authentication required to cancel a generation")
|
|
620
|
+
|
|
621
|
+
async with get_async_session() as session:
|
|
622
|
+
# Query generation
|
|
623
|
+
stmt = select(Generations).where(Generations.id == id)
|
|
624
|
+
result = await session.execute(stmt)
|
|
625
|
+
gen = result.scalar_one_or_none()
|
|
626
|
+
|
|
627
|
+
if not gen:
|
|
628
|
+
raise RuntimeError("Generation not found")
|
|
629
|
+
|
|
630
|
+
# Check board access and permissions
|
|
631
|
+
board_stmt = (
|
|
632
|
+
select(Boards)
|
|
633
|
+
.where(Boards.id == gen.board_id)
|
|
634
|
+
.options(selectinload(Boards.board_members))
|
|
635
|
+
)
|
|
636
|
+
board_result = await session.execute(board_stmt)
|
|
637
|
+
board = board_result.scalar_one_or_none()
|
|
638
|
+
|
|
639
|
+
if not board:
|
|
640
|
+
raise RuntimeError("Board not found")
|
|
641
|
+
|
|
642
|
+
# Check authorization
|
|
643
|
+
is_owner = board.owner_id == auth_context.user_id
|
|
644
|
+
is_editor = any(
|
|
645
|
+
member.user_id == auth_context.user_id and member.role in {"editor", "admin"}
|
|
646
|
+
for member in board.board_members
|
|
647
|
+
)
|
|
648
|
+
is_creator = gen.user_id == auth_context.user_id
|
|
649
|
+
|
|
650
|
+
# Owner can cancel any generation, editor can only cancel their own
|
|
651
|
+
if not is_owner and not (is_editor and is_creator):
|
|
652
|
+
raise RuntimeError(
|
|
653
|
+
"Permission denied: only board owner or generation creator can cancel"
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
# Validate status
|
|
657
|
+
if gen.status not in {"pending", "processing"}:
|
|
658
|
+
raise RuntimeError(
|
|
659
|
+
f"Cannot cancel generation with status '{gen.status}'. "
|
|
660
|
+
"Only pending or processing generations can be cancelled."
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
# Update status to cancelled
|
|
664
|
+
await jobs_repo.update_progress(
|
|
665
|
+
session,
|
|
666
|
+
id,
|
|
667
|
+
status="cancelled",
|
|
668
|
+
progress=float(gen.progress or 0.0),
|
|
669
|
+
error_message="Cancelled by user",
|
|
670
|
+
)
|
|
671
|
+
await session.commit()
|
|
672
|
+
|
|
673
|
+
# Refresh to get updated data
|
|
674
|
+
await session.refresh(gen)
|
|
675
|
+
|
|
676
|
+
logger.info(
|
|
677
|
+
"Generation cancelled",
|
|
678
|
+
generation_id=str(id),
|
|
679
|
+
user_id=str(auth_context.user_id),
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
# Convert to GraphQL type
|
|
683
|
+
from ..types.generation import ArtifactType, GenerationStatus
|
|
684
|
+
from ..types.generation import Generation as GenerationType
|
|
685
|
+
|
|
686
|
+
return GenerationType(
|
|
687
|
+
id=gen.id,
|
|
688
|
+
tenant_id=gen.tenant_id,
|
|
689
|
+
board_id=gen.board_id,
|
|
690
|
+
user_id=gen.user_id,
|
|
691
|
+
generator_name=gen.generator_name,
|
|
692
|
+
artifact_type=ArtifactType(gen.artifact_type),
|
|
693
|
+
storage_url=gen.storage_url,
|
|
694
|
+
thumbnail_url=gen.thumbnail_url,
|
|
695
|
+
additional_files=gen.additional_files or [],
|
|
696
|
+
input_params=gen.input_params or {},
|
|
697
|
+
output_metadata=gen.output_metadata or {},
|
|
698
|
+
parent_generation_id=gen.parent_generation_id,
|
|
699
|
+
input_generation_ids=gen.input_generation_ids or [],
|
|
700
|
+
external_job_id=gen.external_job_id,
|
|
701
|
+
status=GenerationStatus(gen.status),
|
|
702
|
+
progress=float(gen.progress or 0.0),
|
|
703
|
+
error_message=gen.error_message,
|
|
704
|
+
started_at=gen.started_at,
|
|
705
|
+
completed_at=gen.completed_at,
|
|
706
|
+
created_at=gen.created_at,
|
|
707
|
+
updated_at=gen.updated_at,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
async def delete_generation(info: strawberry.Info, id: UUID) -> bool:
|
|
712
|
+
"""
|
|
713
|
+
Delete a generation and its associated storage artifacts.
|
|
714
|
+
|
|
715
|
+
Requires ownership or editor role on the board.
|
|
716
|
+
"""
|
|
717
|
+
auth_context = await get_auth_context_from_info(info)
|
|
718
|
+
if not auth_context or not auth_context.is_authenticated:
|
|
719
|
+
raise RuntimeError("Authentication required to delete a generation")
|
|
720
|
+
|
|
721
|
+
async with get_async_session() as session:
|
|
722
|
+
# Query generation
|
|
723
|
+
stmt = select(Generations).where(Generations.id == id)
|
|
724
|
+
result = await session.execute(stmt)
|
|
725
|
+
gen = result.scalar_one_or_none()
|
|
726
|
+
|
|
727
|
+
if not gen:
|
|
728
|
+
raise RuntimeError("Generation not found")
|
|
729
|
+
|
|
730
|
+
# Check board access and permissions
|
|
731
|
+
board_stmt = (
|
|
732
|
+
select(Boards)
|
|
733
|
+
.where(Boards.id == gen.board_id)
|
|
734
|
+
.options(selectinload(Boards.board_members))
|
|
735
|
+
)
|
|
736
|
+
board_result = await session.execute(board_stmt)
|
|
737
|
+
board = board_result.scalar_one_or_none()
|
|
738
|
+
|
|
739
|
+
if not board:
|
|
740
|
+
raise RuntimeError("Board not found")
|
|
741
|
+
|
|
742
|
+
# Check authorization
|
|
743
|
+
is_owner = board.owner_id == auth_context.user_id
|
|
744
|
+
is_editor = any(
|
|
745
|
+
member.user_id == auth_context.user_id and member.role in {"editor", "admin"}
|
|
746
|
+
for member in board.board_members
|
|
747
|
+
)
|
|
748
|
+
is_creator = gen.user_id == auth_context.user_id
|
|
749
|
+
|
|
750
|
+
# Owner can delete any generation, editor can only delete their own
|
|
751
|
+
if not is_owner and not (is_editor and is_creator):
|
|
752
|
+
raise RuntimeError(
|
|
753
|
+
"Permission denied: only board owner or generation creator can delete"
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
# Delete storage artifacts
|
|
757
|
+
# TODO: Full storage deletion requires storage_key and storage_provider fields
|
|
758
|
+
# to be added to the Generations table. For now, we log the deletion intent.
|
|
759
|
+
# Once those fields are added, use: await storage_manager.delete_artifact(key, provider)
|
|
760
|
+
|
|
761
|
+
if gen.storage_url or gen.thumbnail_url or gen.additional_files:
|
|
762
|
+
logger.info(
|
|
763
|
+
"Storage artifact deletion",
|
|
764
|
+
generation_id=str(id),
|
|
765
|
+
has_storage_url=bool(gen.storage_url),
|
|
766
|
+
has_thumbnail=bool(gen.thumbnail_url),
|
|
767
|
+
additional_files_count=(len(gen.additional_files) if gen.additional_files else 0),
|
|
768
|
+
)
|
|
769
|
+
logger.warning(
|
|
770
|
+
"Storage artifact deletion not yet implemented - requires storage_key and "
|
|
771
|
+
"storage_provider fields in Generations table",
|
|
772
|
+
generation_id=str(id),
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
# Delete generation from database
|
|
776
|
+
await session.delete(gen)
|
|
777
|
+
await session.commit()
|
|
778
|
+
|
|
779
|
+
logger.info(
|
|
780
|
+
"Generation deleted",
|
|
781
|
+
generation_id=str(id),
|
|
782
|
+
user_id=str(auth_context.user_id),
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
return True
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
async def regenerate(info: strawberry.Info, id: UUID) -> Generation:
|
|
789
|
+
"""
|
|
790
|
+
Regenerate from an existing generation.
|
|
791
|
+
|
|
792
|
+
Creates a new generation with the same inputs and sets the original as parent.
|
|
793
|
+
"""
|
|
794
|
+
auth_context = await get_auth_context_from_info(info)
|
|
795
|
+
if not auth_context or not auth_context.is_authenticated or not auth_context.user_id:
|
|
796
|
+
raise RuntimeError("Authentication required to regenerate")
|
|
797
|
+
|
|
798
|
+
async with get_async_session() as session:
|
|
799
|
+
# Query original generation
|
|
800
|
+
stmt = select(Generations).where(Generations.id == id)
|
|
801
|
+
result = await session.execute(stmt)
|
|
802
|
+
original = result.scalar_one_or_none()
|
|
803
|
+
|
|
804
|
+
if not original:
|
|
805
|
+
raise RuntimeError("Original generation not found")
|
|
806
|
+
|
|
807
|
+
# Check board access - require editor or owner role
|
|
808
|
+
board_stmt = (
|
|
809
|
+
select(Boards)
|
|
810
|
+
.where(Boards.id == original.board_id)
|
|
811
|
+
.options(selectinload(Boards.board_members))
|
|
812
|
+
)
|
|
813
|
+
board_result = await session.execute(board_stmt)
|
|
814
|
+
board = board_result.scalar_one_or_none()
|
|
815
|
+
|
|
816
|
+
if not board:
|
|
817
|
+
raise RuntimeError("Board not found")
|
|
818
|
+
|
|
819
|
+
# Check if user is owner or editor
|
|
820
|
+
is_owner = board.owner_id == auth_context.user_id
|
|
821
|
+
is_editor = any(
|
|
822
|
+
member.user_id == auth_context.user_id and member.role in {"editor", "admin"}
|
|
823
|
+
for member in board.board_members
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
if not is_owner and not is_editor:
|
|
827
|
+
raise RuntimeError("Permission denied: only board owner or editor can regenerate")
|
|
828
|
+
|
|
829
|
+
# Validate generator still exists
|
|
830
|
+
generator = generator_registry.get(original.generator_name)
|
|
831
|
+
if generator is None:
|
|
832
|
+
raise RuntimeError(f"Generator '{original.generator_name}' is no longer available")
|
|
833
|
+
|
|
834
|
+
# Create new generation with copied inputs
|
|
835
|
+
new_gen = await jobs_repo.create_generation(
|
|
836
|
+
session,
|
|
837
|
+
tenant_id=original.tenant_id,
|
|
838
|
+
board_id=original.board_id,
|
|
839
|
+
user_id=auth_context.user_id,
|
|
840
|
+
generator_name=original.generator_name,
|
|
841
|
+
artifact_type=original.artifact_type,
|
|
842
|
+
input_params=original.input_params or {},
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
# Set parent and copy input relationships
|
|
846
|
+
new_gen.parent_generation_id = original.id
|
|
847
|
+
new_gen.input_generation_ids = original.input_generation_ids or []
|
|
848
|
+
|
|
849
|
+
await session.commit()
|
|
850
|
+
await session.refresh(new_gen)
|
|
851
|
+
|
|
852
|
+
logger.info(
|
|
853
|
+
"Generation regenerated",
|
|
854
|
+
new_generation_id=str(new_gen.id),
|
|
855
|
+
original_generation_id=str(id),
|
|
856
|
+
user_id=str(auth_context.user_id),
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
# Enqueue job for processing
|
|
860
|
+
process_generation.send(str(new_gen.id))
|
|
861
|
+
logger.info("Regeneration job enqueued", generation_id=str(new_gen.id))
|
|
862
|
+
|
|
863
|
+
# Convert to GraphQL type
|
|
864
|
+
from ..types.generation import ArtifactType, GenerationStatus
|
|
865
|
+
from ..types.generation import Generation as GenerationType
|
|
866
|
+
|
|
867
|
+
return GenerationType(
|
|
868
|
+
id=new_gen.id,
|
|
869
|
+
tenant_id=new_gen.tenant_id,
|
|
870
|
+
board_id=new_gen.board_id,
|
|
871
|
+
user_id=new_gen.user_id,
|
|
872
|
+
generator_name=new_gen.generator_name,
|
|
873
|
+
artifact_type=ArtifactType(new_gen.artifact_type),
|
|
874
|
+
storage_url=new_gen.storage_url,
|
|
875
|
+
thumbnail_url=new_gen.thumbnail_url,
|
|
876
|
+
additional_files=new_gen.additional_files or [],
|
|
877
|
+
input_params=new_gen.input_params or {},
|
|
878
|
+
output_metadata=new_gen.output_metadata or {},
|
|
879
|
+
parent_generation_id=new_gen.parent_generation_id,
|
|
880
|
+
input_generation_ids=new_gen.input_generation_ids or [],
|
|
881
|
+
external_job_id=new_gen.external_job_id,
|
|
882
|
+
status=GenerationStatus(new_gen.status),
|
|
883
|
+
progress=float(new_gen.progress or 0.0),
|
|
884
|
+
error_message=new_gen.error_message,
|
|
885
|
+
started_at=new_gen.started_at,
|
|
886
|
+
completed_at=new_gen.completed_at,
|
|
887
|
+
created_at=new_gen.created_at,
|
|
888
|
+
updated_at=new_gen.updated_at,
|
|
889
|
+
)
|