From 2e5b47a4a218c576c8202adbe77fff998c40ecd8 Mon Sep 17 00:00:00 2001 From: kitos Date: Thu, 28 May 2026 14:36:25 +0200 Subject: [PATCH] feat(campaigns): delete campaign button + defer Jira to Activate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: add DELETE /campaigns/{id}?delete_tests=bool endpoint - Backend: add delete_campaign() service — handles draft-only restriction, optional test deletion, nullifies child campaign FKs - Backend: remove early Jira ticket creation from POST /campaigns, POST /campaigns/{id}/tests, and POST /campaigns/from-threat-actor - Backend: activate endpoint now creates campaign Jira ticket if missing, then creates test tickets (all deferred from creation to activation) - Frontend: add deleteCampaign() API function to campaigns.ts - Frontend: two-step confirmation dialog on CampaignDetailPage — first confirms deletion, then asks whether to also delete associated tests Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routers/campaigns.py | 98 +++++++--------- backend/app/services/campaign_crud_service.py | 56 +++++++++ frontend/src/api/campaigns.ts | 10 ++ frontend/src/pages/CampaignDetailPage.tsx | 107 ++++++++++++++++++ 4 files changed, 212 insertions(+), 59 deletions(-) diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py index af78199..679e12b 100644 --- a/backend/app/routers/campaigns.py +++ b/backend/app/routers/campaigns.py @@ -21,6 +21,7 @@ from app.services.campaign_crud_service import ( activate_campaign as crud_activate, complete_campaign as crud_complete, create_campaign as crud_create, + delete_campaign as crud_delete, get_campaign_detail as crud_get_detail, get_campaign_history as crud_get_history, get_campaign_progress_data as crud_get_progress, @@ -133,17 +134,6 @@ def create_campaign( ) uow.commit() - # Auto-create Jira ticket for campaign under OFS-9107 (non-fatal) - try: - from app.services.jira_service import auto_create_campaign_issue - from app.models.campaign import Campaign as CampaignModel - campaign_obj = db.query(CampaignModel).filter(CampaignModel.id == campaign_id).first() - if campaign_obj: - auto_create_campaign_issue(db, campaign_obj, current_user) - db.commit() - except Exception: - logger.exception("Jira campaign ticket creation failed for campaign %s", campaign_id) - return result @@ -195,6 +185,37 @@ def update_campaign( return result +# --------------------------------------------------------------------------- +# DELETE /campaigns/{id} — Delete campaign +# --------------------------------------------------------------------------- + +@router.delete("/{campaign_id}", status_code=204) +def delete_campaign( + campaign_id: str, + delete_tests: bool = Query(False, description="Also delete associated tests"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete a campaign. Only draft campaigns can be deleted (admins can delete any).""" + with UnitOfWork(db) as uow: + crud_delete( + db, + campaign_id, + deleter_id=current_user.id, + deleter_role=current_user.role, + delete_tests=delete_tests, + ) + log_action( + db, + user_id=current_user.id, + action="delete_campaign", + entity_type="campaign", + entity_id=campaign_id, + details={"delete_tests": delete_tests}, + ) + uow.commit() + + # --------------------------------------------------------------------------- # POST /campaigns/{id}/tests — Add test to campaign # --------------------------------------------------------------------------- @@ -218,32 +239,6 @@ def add_test_to_campaign( ) uow.commit() - # If the campaign has a Jira ticket and the test doesn't, create a test - # ticket nested under the campaign ticket (non-fatal). - try: - from app.services.jira_service import ( - auto_create_test_issue, - get_campaign_jira_key, - get_test_jira_key, - ) - from app.models.test import Test as TestModel - campaign_jira_key = get_campaign_jira_key(db, campaign_id) - if campaign_jira_key: - existing_test_key = get_test_jira_key(db, payload.test_id) - if not existing_test_key: - test_obj = db.query(TestModel).filter(TestModel.id == payload.test_id).first() - if test_obj: - auto_create_test_issue( - db, test_obj, current_user, - parent_ticket_override=campaign_jira_key, - ) - db.commit() - except Exception: - logger.exception( - "Jira test ticket creation failed for test %s in campaign %s", - payload.test_id, campaign_id, - ) - return result @@ -298,15 +293,18 @@ def activate_campaign( uow.commit() db.refresh(campaign) - # Create Jira test tickets for any campaign tests that don't have one yet, - # nested under the campaign's Jira ticket (non-fatal). + # Create Jira tickets for campaign and tests at activation time (non-fatal). + # Campaign ticket is created here if it doesn't already exist (deferred from creation). try: from app.services.jira_service import ( + auto_create_campaign_issue, auto_create_test_issue, get_campaign_jira_key, get_test_jira_key, ) campaign_jira_key = get_campaign_jira_key(db, campaign_id) + if not campaign_jira_key: + campaign_jira_key = auto_create_campaign_issue(db, campaign, current_user) if campaign_jira_key: for ct in campaign.campaign_tests: if ct.test and not get_test_jira_key(db, ct.test.id): @@ -314,10 +312,10 @@ def activate_campaign( db, ct.test, current_user, parent_ticket_override=campaign_jira_key, ) - db.commit() + db.commit() except Exception: logger.exception( - "Jira test ticket creation failed during activation of campaign %s", + "Jira ticket creation failed during activation of campaign %s", campaign_id, ) @@ -398,24 +396,6 @@ def generate_campaign_from_actor( ) uow.commit() - # Auto-create Jira tickets: campaign under OFS-9107, each test under campaign ticket (non-fatal) - try: - from app.services.jira_service import auto_create_campaign_issue, auto_create_test_issue - db.refresh(campaign) - campaign_ticket = auto_create_campaign_issue(db, campaign, current_user) - if campaign_ticket: - for ct in campaign.campaign_tests: - if ct.test: - auto_create_test_issue( - db, ct.test, current_user, - parent_ticket_override=campaign_ticket, - ) - db.commit() - except Exception: - logger.exception( - "Jira ticket creation failed for auto-generated campaign %s", campaign.id - ) - return serialize_campaign(db, campaign) diff --git a/backend/app/services/campaign_crud_service.py b/backend/app/services/campaign_crud_service.py index 34a503c..edae68b 100644 --- a/backend/app/services/campaign_crud_service.py +++ b/backend/app/services/campaign_crud_service.py @@ -425,6 +425,62 @@ def schedule_campaign( return campaign +def delete_campaign( + db: Session, + campaign_id: str, + *, + deleter_id: uuid.UUID, + deleter_role: str, + delete_tests: bool = False, +) -> None: + """Delete a campaign. + + Only draft campaigns can be deleted unless the caller is admin. + If delete_tests=True, the associated Test objects are also deleted. + Does not commit; caller commits. + """ + campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() + if not campaign: + raise EntityNotFoundError("Campaign", campaign_id) + + if campaign.status != "draft" and deleter_role != "admin": + raise BusinessRuleViolation("Only draft campaigns can be deleted") + + if str(campaign.created_by) != str(deleter_id) and deleter_role != "admin": + raise PermissionViolation("Only the creator or admin can delete this campaign") + + # Collect test IDs before removing associations + campaign_tests = ( + db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign_id).all() + ) + test_ids = [ct.test_id for ct in campaign_tests] + + # Remove CampaignTest join rows (clear depends_on refs first to avoid FK cycles) + for ct in campaign_tests: + ct.depends_on = None + db.flush() + for ct in campaign_tests: + db.delete(ct) + db.flush() + + # Optionally delete the associated tests + if delete_tests: + for test_id in test_ids: + test = db.query(Test).filter(Test.id == test_id).first() + if test: + db.delete(test) + db.flush() + + # Null-out parent_campaign_id on child campaigns to avoid FK violation + db.query(Campaign).filter(Campaign.parent_campaign_id == campaign.id).update( + {"parent_campaign_id": None} + ) + db.flush() + + db.delete(campaign) + db.flush() + + def get_campaign_history(db: Session, campaign_id: str) -> dict: """List all child campaigns (execution history) of a recurring campaign. diff --git a/frontend/src/api/campaigns.ts b/frontend/src/api/campaigns.ts index c933b26..d9ce326 100644 --- a/frontend/src/api/campaigns.ts +++ b/frontend/src/api/campaigns.ts @@ -184,6 +184,16 @@ export async function scheduleCampaign( return data; } +/** Delete a campaign. Only draft campaigns can be deleted (admins can delete any). */ +export async function deleteCampaign( + campaignId: string, + deleteTests: boolean = false, +): Promise { + await client.delete(`/campaigns/${campaignId}`, { + params: { delete_tests: deleteTests }, + }); +} + /** Get execution history (child campaigns) for a recurring campaign. */ export async function getCampaignHistory(campaignId: string): Promise<{ campaign_id: string; diff --git a/frontend/src/pages/CampaignDetailPage.tsx b/frontend/src/pages/CampaignDetailPage.tsx index 375515e..0d59327 100644 --- a/frontend/src/pages/CampaignDetailPage.tsx +++ b/frontend/src/pages/CampaignDetailPage.tsx @@ -20,6 +20,7 @@ import { getCampaign, activateCampaign, completeCampaign, + deleteCampaign, removeTestFromCampaign, scheduleCampaign, getCampaignHistory, @@ -63,6 +64,8 @@ export default function CampaignDetailPage() { const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [showAddTestModal, setShowAddTestModal] = useState(false); + // 0 = hidden, 1 = first confirmation, 2 = ask about tests + const [deleteStep, setDeleteStep] = useState<0 | 1 | 2>(0); const showToast = (message: string, type: "success" | "error") => { setToast({ message, type }); @@ -120,6 +123,18 @@ export default function CampaignDetailPage() { onError: (err: Error) => showToast(err.message, "error"), }); + const deleteMutation = useMutation({ + mutationFn: (deleteTests: boolean) => deleteCampaign(campaignId!, deleteTests), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["campaigns"] }); + navigate("/campaigns"); + }, + onError: (err: Error) => { + setDeleteStep(0); + showToast(err.message, "error"); + }, + }); + const { data: historyData } = useQuery({ queryKey: ["campaign-history", campaignId], queryFn: () => getCampaignHistory(campaignId!), @@ -249,6 +264,17 @@ export default function CampaignDetailPage() { {/* Actions */}
+ {/* Delete — only for draft campaigns (admins see it regardless) */} + {(campaign.status === "draft" || role === "admin") && canManage && ( + + )} {canManage && campaign.status === "draft" && (
)} + + {/* Delete confirmation — Step 1 */} + {deleteStep === 1 && ( +
+
+
+
+ +
+

Delete Campaign

+
+

+ Are you sure you want to delete{" "} + {campaign.name}? +

+

This action cannot be undone.

+
+ + +
+
+
+ )} + + {/* Delete confirmation — Step 2: ask about tests */} + {deleteStep === 2 && ( +
+
+
+
+ +
+

Delete Associated Tests?

+
+

+ This campaign has{" "} + {campaign.tests.length}{" "} + associated test{campaign.tests.length !== 1 ? "s" : ""}. Do you also want to + delete them? +

+
+ + + +
+
+
+ )} ); }