This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new 51b6af3574 feat(python-notebook-migration): add backend endpoint to 
delete a workflow's stored notebook and mapping (#7132)
51b6af3574 is described below

commit 51b6af3574f41fa3f477a50eee7c7c0c5bc7bee4
Author: Ryan Zhang <[email protected]>
AuthorDate: Thu Jul 30 15:58:13 2026 -0700

    feat(python-notebook-migration): add backend endpoint to delete a 
workflow's stored notebook and mapping (#7132)
    
    ### What changes were proposed in this PR?
    
    Adds a single backend REST endpoint to the notebook-migration service
    that deletes a workflow's stored notebook and its workflow-to-notebook
    mapping.
    
    Until now the service could store and fetch a workflow's notebook
    (`store-notebook-and-mapping`, `fetch-notebook-and-mapping`) but had no
    way to remove one. Once stored, a notebook and its mapping stayed in the
    database with no server-side operation to clear them. This left the
    persisted rows behind whenever a user closed or discarded a migrated
    notebook, and it blocked the end-to-end deletion flow, which needs a
    backend call to remove the notebook before the UI can reset the state
    that reflects whether a notebook exists.
    
    New endpoint: `POST /notebook-migration/delete-notebook-and-mapping`.
    
    Behavior:
    - Reads `wid` from the JSON body. `vid` is not required: `notebook.wid`
    is UNIQUE (one notebook per workflow), so `wid` alone identifies the
    row.
    - Requires write access to the workflow. Returns 403 when the caller
    lacks it, consistent with the store and fetch endpoints.
    - Deletes the `notebook` row for the workflow. The
    `workflow_notebook_mapping` rows are removed by the existing `ON DELETE
    CASCADE` foreign key, so a single delete clears both tables and cannot
    leave them inconsistent.
    - Returns `{"success": true, "deleted": <count>}`, where the count is 1
    when a notebook was removed and 0 when nothing was stored, so the caller
    can tell a real deletion from a no-op.
    - Idempotent: deleting when nothing is stored returns success with
    `deleted: 0`.
    
    POST with a JSON body is used to stay consistent with the existing store
    and fetch endpoints, which already read `wid` from a JSON body.
    
    This is backend only. The frontend service method, the close-panel
    wiring, and any Jupyter-server cleanup are intentionally left to the
    follow-on end-to-end deletion work that consumes this endpoint.
    
    Note: if the workflow itself is deleted, the notebook and mapping are
    already removed by existing cascades, so this endpoint targets the case
    where the workflow survives but its notebook should be discarded.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7131
    Parent issue #4301
    
    ### How was this PR tested?
    
    Added resource spec cases in `NotebookMigrationResourceSpec` covering:
    - Deleting a workflow that has a stored notebook removes both the
    notebook row and its mapping rows (cascade verified) and reports
    `deleted: 1`.
    - Idempotent delete: returns success with `deleted: 0` when nothing is
    stored.
    - Returns 403 and deletes nothing when the caller has only read access.
    - Returns 500 on a malformed JSON body.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 4.8)
---
 .../resource/NotebookMigrationResource.scala       | 86 +++++++++++++++++++++-
 .../resource/NotebookMigrationResourceSpec.scala   | 78 ++++++++++++++++++++
 2 files changed, 161 insertions(+), 3 deletions(-)

diff --git 
a/notebook-migration-service/src/main/scala/org/apache/texera/service/resource/NotebookMigrationResource.scala
 
b/notebook-migration-service/src/main/scala/org/apache/texera/service/resource/NotebookMigrationResource.scala
index b8ebbb9f19..ac41ccd28d 100644
--- 
a/notebook-migration-service/src/main/scala/org/apache/texera/service/resource/NotebookMigrationResource.scala
+++ 
b/notebook-migration-service/src/main/scala/org/apache/texera/service/resource/NotebookMigrationResource.scala
@@ -17,7 +17,7 @@
 
 package org.apache.texera.service.resource
 
-import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
 import com.fasterxml.jackson.module.scala.DefaultScalaModule
 import com.typesafe.scalalogging.LazyLogging
 import io.dropwizard.auth.Auth
@@ -49,6 +49,30 @@ object NotebookMigrationResource extends LazyLogging {
   private def successUrlJson(url: String): String =
     mapper.writeValueAsString(mapper.createObjectNode().put("success", 
true).put("url", url))
 
+  // Build a {"success": true, "deleted": <count>} body via the mapper. The 
count lets the
+  // caller distinguish a real deletion (1) from a no-op when nothing was 
stored (0).
+  private def successDeletedJson(deleted: Int): String =
+    mapper.writeValueAsString(
+      mapper.createObjectNode().put("success", true).put("deleted", deleted)
+    )
+
+  // Read the required integer `wid` from a request body. Returns Left(400) 
when the field is
+  // missing or not an integer so the caller can short-circuit. Without this a 
missing wid NPEs
+  // into a 500 and a non-integer wid silently coerces to 0 via asInt().
+  private def readWid(json: JsonNode): Either[Response, java.lang.Integer] = {
+    val widNode = json.get("wid")
+    if (widNode == null || !widNode.isInt) {
+      Left(
+        Response
+          .status(Response.Status.BAD_REQUEST)
+          .entity(errorJson("Missing or invalid 'wid'"))
+          .build()
+      )
+    } else {
+      Right(widNode.asInt())
+    }
+  }
+
   private val jupyterUrl = StorageConfig.jupyterURL
   private val jupyterToken = StorageConfig.jupyterToken
   // The token is passed as a URL param so the browser iframe can authenticate 
when loading the notebook.
@@ -223,7 +247,10 @@ object NotebookMigrationResource extends LazyLogging {
     try {
       val json = mapper.readTree(body)
 
-      val wid: java.lang.Integer = json.get("wid").asInt()
+      val wid: java.lang.Integer = readWid(json) match {
+        case Left(badRequest) => return badRequest
+        case Right(w)         => w
+      }
       val vid: java.lang.Integer = json.get("vid").asInt()
       val mappingNode = json.get("mapping")
       val notebookNode = json.get("notebook")
@@ -311,7 +338,10 @@ object NotebookMigrationResource extends LazyLogging {
     try {
       val json = mapper.readTree(body)
 
-      val wid: java.lang.Integer = json.get("wid").asInt()
+      val wid: java.lang.Integer = readWid(json) match {
+        case Left(badRequest) => return badRequest
+        case Right(w)         => w
+      }
       val vid: java.lang.Integer = json.get("vid").asInt()
 
       // Only a user with write access to the workflow may fetch its notebook.
@@ -374,6 +404,49 @@ object NotebookMigrationResource extends LazyLogging {
           .build()
     }
   }
+
+  // Delete notebook + mapping for a workflow. The notebook -> 
workflow_notebook_mapping FK is
+  // ON DELETE CASCADE, so deleting the notebook row removes its mapping rows 
too. notebook.wid
+  // is UNIQUE (one notebook per workflow), so wid alone identifies the row 
and vid is not needed.
+  def deleteNotebookAndMapping(body: String, uid: java.lang.Integer): Response 
= {
+    try {
+      val json = mapper.readTree(body)
+
+      val wid: java.lang.Integer = readWid(json) match {
+        case Left(badRequest) => return badRequest
+        case Right(w)         => w
+      }
+
+      // Only a user with write access to the workflow may delete its notebook.
+      if (!WorkflowAccessResource.hasWriteAccess(wid, uid)) {
+        return Response
+          .status(Response.Status.FORBIDDEN)
+          .entity(errorJson(s"No write access to workflow $wid"))
+          .build()
+      }
+
+      val dsl = SqlServer.getInstance().createDSLContext()
+
+      // execute() returns the affected row count: 1 when a notebook was 
removed, 0 when the
+      // workflow had nothing stored (idempotent no-op).
+      val deleted: Int = SqlServer.withTransaction(dsl) { ctx =>
+        ctx
+          .deleteFrom(Notebook.NOTEBOOK)
+          .where(Notebook.NOTEBOOK.WID.eq(wid))
+          .execute()
+      }
+
+      Response.ok(successDeletedJson(deleted)).build()
+
+    } catch {
+      case NonFatal(e) =>
+        logger.error("Error deleting notebook and mapping", e)
+        Response
+          .status(Response.Status.INTERNAL_SERVER_ERROR)
+          .entity(errorJson(e.getMessage))
+          .build()
+    }
+  }
 }
 
 @Path("/notebook-migration")
@@ -416,4 +489,11 @@ class NotebookMigrationResource extends LazyLogging {
     logger.info("Fetching notebook and mapping")
     NotebookMigrationResource.fetchNotebookAndMapping(body, user.getUid)
   }
+
+  @POST
+  @Path("/delete-notebook-and-mapping")
+  def deleteNotebookAndMapping(body: String, @Auth user: SessionUser): 
Response = {
+    logger.info("Deleting notebook and mapping")
+    NotebookMigrationResource.deleteNotebookAndMapping(body, user.getUid)
+  }
 }
diff --git 
a/notebook-migration-service/src/test/scala/org/apache/texera/service/resource/NotebookMigrationResourceSpec.scala
 
b/notebook-migration-service/src/test/scala/org/apache/texera/service/resource/NotebookMigrationResourceSpec.scala
index 35c3f47377..ea6505a86b 100644
--- 
a/notebook-migration-service/src/test/scala/org/apache/texera/service/resource/NotebookMigrationResourceSpec.scala
+++ 
b/notebook-migration-service/src/test/scala/org/apache/texera/service/resource/NotebookMigrationResourceSpec.scala
@@ -161,6 +161,9 @@ class NotebookMigrationResourceSpec
   private def fetchPayload(vid: Integer = seededVid): String =
     s"""{"wid": $testWid, "vid": $vid}"""
 
+  private def deletePayload(): String =
+    s"""{"wid": $testWid}"""
+
   private val resource = new NotebookMigrationResource()
 
   private def sessionUser(uid: Integer): SessionUser = {
@@ -326,6 +329,81 @@ class NotebookMigrationResourceSpec
     entity should include("\"v1\"")
   }
 
+  // -- deleteNotebookAndMapping 
-----------------------------------------------
+
+  "deleteNotebookAndMapping" should "remove the notebook and cascade to its 
mapping, reporting deleted=1" in {
+    NotebookMigrationResource.storeNotebookAndMapping(storePayload(), 
writerUid)
+    getDSLContext.fetchCount(NOTEBOOK) shouldBe 1
+    getDSLContext.fetchCount(WORKFLOW_NOTEBOOK_MAPPING) shouldBe 1
+
+    val response = 
NotebookMigrationResource.deleteNotebookAndMapping(deletePayload(), writerUid)
+    response.getStatus shouldBe Response.Status.OK.getStatusCode
+    response.getEntity.toString should include("\"deleted\":1")
+
+    // Deleting the notebook row cascades to workflow_notebook_mapping via the 
FK.
+    getDSLContext.fetchCount(NOTEBOOK) shouldBe 0
+    getDSLContext.fetchCount(WORKFLOW_NOTEBOOK_MAPPING) shouldBe 0
+  }
+
+  it should "be idempotent, returning success with deleted=0 when nothing is 
stored" in {
+    val response = 
NotebookMigrationResource.deleteNotebookAndMapping(deletePayload(), writerUid)
+    response.getStatus shouldBe Response.Status.OK.getStatusCode
+    response.getEntity.toString should include("\"deleted\":0")
+  }
+
+  it should "return 403 Forbidden and delete nothing when the user lacks write 
access" in {
+    NotebookMigrationResource.storeNotebookAndMapping(storePayload(), 
writerUid)
+
+    // readerUid holds only READ access; delete requires WRITE.
+    NotebookMigrationResource
+      .deleteNotebookAndMapping(deletePayload(), readerUid)
+      .getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+
+    getDSLContext.fetchCount(NOTEBOOK) shouldBe 1
+    getDSLContext.fetchCount(WORKFLOW_NOTEBOOK_MAPPING) shouldBe 1
+  }
+
+  it should "return 500 when the request body is malformed JSON" in {
+    // Exercises the NonFatal catch path in deleteNotebookAndMapping.
+    resource
+      .deleteNotebookAndMapping("not json", sessionUser(writerUid))
+      .getStatus shouldBe 500
+  }
+
+  // -- wid validation 
---------------------------------------------------------
+
+  "store/fetch/delete" should "return 400 Bad Request when 'wid' is missing 
from the body" in {
+    // A missing wid must be a client error, not a 500 from the null.asInt() 
NPE.
+    val noWid = s"""{"vid": $seededVid}"""
+    NotebookMigrationResource
+      .storeNotebookAndMapping(noWid, writerUid)
+      .getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode
+    NotebookMigrationResource
+      .fetchNotebookAndMapping(noWid, writerUid)
+      .getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode
+    NotebookMigrationResource
+      .deleteNotebookAndMapping("""{}""", writerUid)
+      .getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode
+
+    getDSLContext.fetchCount(NOTEBOOK) shouldBe 0
+  }
+
+  it should "return 400 Bad Request when 'wid' is not an integer" in {
+    // A non-integer wid must be rejected rather than silently coerced to 0 by 
asInt().
+    val badWid = s"""{"wid": "not-an-int", "vid": $seededVid}"""
+    NotebookMigrationResource
+      .storeNotebookAndMapping(badWid, writerUid)
+      .getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode
+    NotebookMigrationResource
+      .fetchNotebookAndMapping(badWid, writerUid)
+      .getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode
+    NotebookMigrationResource
+      .deleteNotebookAndMapping(badWid, writerUid)
+      .getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode
+
+    getDSLContext.fetchCount(NOTEBOOK) shouldBe 0
+  }
+
   // -- workflow write-access enforcement 
--------------------------------------
 
   "store/fetch" should "return 403 Forbidden when the user lacks write access 
to the workflow" in {

Reply via email to