Add module delete + fail-fast on duplicate module name in wizard.

Delete button lives in module-detail header, refuses to delete a
running module, and clears run_log history first since it doesn't
cascade from module. Wizard now returns 409 on duplicate name before
touching the destination, so a failed resubmit doesn't redundantly
rerun CREATE TABLE / COMMENT ON on the dest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-22 08:28:51 -04:00
parent 574ada5258
commit d952b48a4e
3 changed files with 39 additions and 0 deletions

View File

@ -177,6 +177,26 @@ def list_modules() -> list[dict]:
return [dict(r) for r in c.execute("SELECT * FROM module ORDER BY name")] return [dict(r) for r in c.execute("SELECT * FROM module ORDER BY name")]
class ModuleRunning(RuntimeError):
"""Raised by delete_module when the module is currently running."""
def delete_module(module_id: int) -> bool:
"""Delete a module and its run_log history. Watermarks, hooks, and
group_member rows cascade via FK. Refuses to delete if the module
is currently running."""
with db.connect() as c:
row = c.execute("SELECT running FROM module WHERE id=?",
(module_id,)).fetchone()
if row is None:
return False
if row[0]:
raise ModuleRunning(f"module id={module_id} is currently running")
c.execute("DELETE FROM run_log WHERE module_id=?", (module_id,))
cur = c.execute("DELETE FROM module WHERE id=?", (module_id,))
return cur.rowcount > 0
def set_next_resolved_query(module_id: int, sql: str) -> None: def set_next_resolved_query(module_id: int, sql: str) -> None:
with db.connect() as c: with db.connect() as c:
c.execute("UPDATE module SET next_resolved_query=?, " c.execute("UPDATE module SET next_resolved_query=?, "

View File

@ -131,6 +131,17 @@ def module_detail(request: Request, module_id: int):
) )
@_router.post("/modules/{module_id}/delete")
def module_delete(module_id: int):
if repo.get_module(module_id) is None:
raise HTTPException(404, f"module id={module_id} not found")
try:
repo.delete_module(module_id)
except repo.ModuleRunning as e:
raise HTTPException(409, str(e))
return RedirectResponse(url="/", status_code=303)
@_router.post("/modules/{module_id}/run") @_router.post("/modules/{module_id}/run")
async def module_run_action(module_id: int, request: Request): async def module_run_action(module_id: int, request: Request):
form = await request.form() form = await request.form()
@ -293,6 +304,10 @@ async def wizard_create(request: Request):
dest_description = (form.get("dest_description") or "").strip() or None dest_description = (form.get("dest_description") or "").strip() or None
picked = form.getlist("col") picked = form.getlist("col")
if repo.get_module_by_name(module_name) is not None:
raise HTTPException(
409, f"module name {module_name!r} already exists — pick another")
src_conn = repo.get_connection(source_connection_id) src_conn = repo.get_connection(source_connection_id)
if src_conn is None: if src_conn is None:
raise HTTPException(404, f"connection id={source_connection_id} not found") raise HTTPException(404, f"connection id={source_connection_id} not found")

View File

@ -19,6 +19,10 @@
<input type="hidden" name="dry_run" value="1"> <input type="hidden" name="dry_run" value="1">
<button type="submit">Dry run</button> <button type="submit">Dry run</button>
</form> </form>
<form class="inline" method="post" action="/modules/{{ module.id }}/delete"
onsubmit="return confirm('Delete module {{ module.name }}? This removes the module and its run history. The dest table is NOT dropped.')">
<button type="submit" class="ghost" style="color:var(--danger)">Delete</button>
</form>
</span> </span>
</header> </header>
<div class="body"> <div class="body">