Skip to content

Commit c68f4c1

Browse files
committed
add: publish personal notebooks to global
1 parent 5b1ab71 commit c68f4c1

5 files changed

Lines changed: 135 additions & 11 deletions

File tree

api/app/repositories/lab.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -283,29 +283,53 @@ def clear_outputs(
283283

284284

285285
def fork_notebook(
286-
db: Session, notebook_id: int, current_user_id: int
286+
db: Session,
287+
notebook_id: int,
288+
current_user_id: int,
289+
target_visibility: str = "personal",
287290
) -> Optional[lab_models.LabNotebook]:
288-
"""Duplicate a visible notebook into a new personal one owned by the current user.
291+
"""Duplicate a visible notebook into a new one owned by the current user.
292+
293+
``target_visibility`` selects where the copy lands:
289294
290-
Cell ids inside ``source`` are regenerated so the original and the fork
295+
- ``"personal"`` (default) — the classic "fork to a private copy" flow,
296+
used when readers want to mutate / execute a library or shared notebook.
297+
- ``"global"`` — "publish to global" flow, used by an owner to share their
298+
personal notebook with the rest of the workbench. Library notebooks
299+
cannot be published this way (use the CLI seeder instead).
300+
301+
Cell ids inside ``source`` are regenerated so the original and the copy
291302
can be open simultaneously without execution conflicts; ``cell_outputs``
292303
is rewritten to use the new ids.
293304
"""
305+
if target_visibility not in ("personal", "global"):
306+
raise ValueError("target_visibility must be 'personal' or 'global'")
307+
294308
src = get_notebook_by_id(db, notebook_id, current_user_id)
295309
if src is None:
296310
return None
297311

312+
if target_visibility == "global" and src.visibility == "library":
313+
raise ValueError(
314+
"library notebooks cannot be published to global; seed via CLI"
315+
)
316+
298317
new_source, id_map = _regenerate_cell_ids(src.source or "")
299318
new_outputs: dict[str, list[dict]] = {}
300319
for old_id, new_id in id_map.items():
301320
if old_id in (src.cell_outputs or {}):
302321
new_outputs[new_id] = src.cell_outputs[old_id]
303322

323+
# Personal forks keep the "(fork)" suffix so the tree disambiguates them
324+
# from the source. Global publishes keep the original name — they're the
325+
# canonical shared copy.
326+
name = src.name if target_visibility == "global" else f"{src.name} (fork)"
327+
304328
fork = lab_models.LabNotebook(
305329
user_id=current_user_id,
306330
folder_id=None,
307-
visibility="personal",
308-
name=f"{src.name} (fork)",
331+
visibility=target_visibility,
332+
name=name,
309333
description=src.description,
310334
source=new_source,
311335
cell_outputs=new_outputs,

api/app/routers/lab.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import json
10+
from typing import Literal
1011

1112
from fastapi import (
1213
APIRouter,
@@ -222,10 +223,16 @@ async def clear_outputs(
222223
)
223224
async def fork_notebook(
224225
notebook_id: int,
226+
visibility: Literal["personal", "global"] = "personal",
225227
db: Session = Depends(get_db),
226228
user: user_schemas.User = Security(get_current_active_user, scopes=["lab:create"]),
227229
):
228-
nb = lab_repository.fork_notebook(db, notebook_id, current_user_id=user.id)
230+
try:
231+
nb = lab_repository.fork_notebook(
232+
db, notebook_id, current_user_id=user.id, target_visibility=visibility
233+
)
234+
except ValueError as e:
235+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
229236
if nb is None:
230237
raise HTTPException(
231238
status_code=status.HTTP_404_NOT_FOUND, detail="Notebook not found"

api/app/tests/api/test_lab.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,68 @@ def test_fork_global_notebook_creates_personal_copy(
361361
assert "aaaa1111-aaaa-aaaa-aaaa-aaaaaaaaaaaa" not in data["source"]
362362
assert "[id=" in data["source"]
363363

364+
@pytest.mark.parametrize("scopes", [["lab:create"]])
365+
def test_publish_personal_to_global_creates_global_copy(
366+
self,
367+
client: TestClient,
368+
db: Session,
369+
api_tester_user: user_models.User,
370+
auth_token: auth.Token,
371+
):
372+
original_source = (
373+
"# %% [id=aaaa2222-aaaa-aaaa-aaaa-aaaaaaaaaaaa] code\n"
374+
"y = 2\n"
375+
)
376+
nb = lab_repository.create_notebook(
377+
db,
378+
lab_schemas.LabNotebookCreate(
379+
name="shareable",
380+
visibility="personal",
381+
source=original_source,
382+
),
383+
current_user_id=api_tester_user.id,
384+
)
385+
response = client.post(
386+
f"/tech-lab/notebooks/{nb.id}/fork?visibility=global",
387+
headers={"Authorization": "Bearer " + auth_token},
388+
)
389+
assert response.status_code == status.HTTP_201_CREATED, response.text
390+
data = response.json()
391+
assert data["user_id"] == api_tester_user.id
392+
assert data["visibility"] == "global"
393+
# Global publish keeps the original name (no "(fork)" suffix).
394+
assert data["name"] == "shareable"
395+
assert data["folder_id"] is None
396+
assert "aaaa2222-aaaa-aaaa-aaaa-aaaaaaaaaaaa" not in data["source"]
397+
assert "[id=" in data["source"]
398+
# Personal original is untouched.
399+
db.refresh(nb)
400+
assert nb.visibility == "personal"
401+
402+
@pytest.mark.parametrize("scopes", [["lab:create"]])
403+
def test_publish_library_to_global_rejected(
404+
self,
405+
client: TestClient,
406+
db: Session,
407+
api_tester_user: user_models.User,
408+
auth_token: auth.Token,
409+
):
410+
nb = lab_repository.create_notebook(
411+
db,
412+
lab_schemas.LabNotebookCreate(
413+
name="lib-thing",
414+
visibility="library",
415+
source="# %% code\nprint('hi')\n",
416+
),
417+
current_user_id=api_tester_user.id,
418+
via_api=False,
419+
)
420+
response = client.post(
421+
f"/tech-lab/notebooks/{nb.id}/fork?visibility=global",
422+
headers={"Authorization": "Bearer " + auth_token},
423+
)
424+
assert response.status_code == status.HTTP_400_BAD_REQUEST
425+
364426

365427
class TestLabExecution(ApiTester):
366428
"""Execute-flow tests with the celery task stubbed to a synchronous runner."""

frontend/src/components/notebooks/NotebookEditor.vue

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
faBroom,
2323
faPlus,
2424
faFileLines,
25+
faGlobe,
2526
} from "@fortawesome/free-solid-svg-icons";
2627
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
2728
import KernelStatusPill from "./KernelStatusPill.vue";
@@ -450,6 +451,26 @@ async function fork() {
450451
}
451452
}
452453
454+
async function publishToGlobal() {
455+
if (!currentNotebook.value) return;
456+
if (
457+
!confirm(
458+
"Create a global copy of this notebook? Everyone in the workbench will be able to view and fork it. The personal original stays put.",
459+
)
460+
)
461+
return;
462+
try {
463+
const gnb = await notebooksStore.forkNotebook(
464+
currentNotebook.value.id,
465+
"global",
466+
);
467+
toastsStore.push(`Published "${gnb.name}" to Global.`, "success");
468+
emit("select-notebook", gnb);
469+
} catch (err) {
470+
toastsStore.push(`Publish failed: ${err?.message || err}`, "danger");
471+
}
472+
}
473+
453474
async function exportIpynb() {
454475
if (!currentNotebook.value) return;
455476
await flushSave(currentNotebook.value.id);
@@ -617,6 +638,15 @@ const saveLabel = computed(() => {
617638
<FontAwesomeIcon :icon="faCodeBranch" class="me-1" />
618639
Fork to personal
619640
</button>
641+
<button
642+
v-if="isOwner && currentNotebook.visibility === 'personal'"
643+
class="btn btn-outline-info btn-sm"
644+
@click="publishToGlobal"
645+
title="Create a global copy others can view and fork"
646+
>
647+
<FontAwesomeIcon :icon="faGlobe" class="me-1" />
648+
Publish to global
649+
</button>
620650
</div>
621651
</template>
622652
<template v-else>

frontend/src/stores/notebooks.store.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,14 @@ export const useNotebooksStore = defineStore({
108108
delete this.saveStatus[id];
109109
await this.loadTree();
110110
},
111-
async forkNotebook(id) {
111+
async forkNotebook(id, visibility = "personal") {
112112
this.status.forking = true;
113113
try {
114-
const nb = await fetchWrapper.post(
115-
`${baseUrl}/notebooks/${id}/fork`,
116-
{},
117-
);
114+
const url =
115+
visibility === "personal"
116+
? `${baseUrl}/notebooks/${id}/fork`
117+
: `${baseUrl}/notebooks/${id}/fork?visibility=${encodeURIComponent(visibility)}`;
118+
const nb = await fetchWrapper.post(url, {});
118119
this.notebooks[nb.id] = nb;
119120
await this.loadTree();
120121
return nb;

0 commit comments

Comments
 (0)