feat(campaigns): delete campaign button + defer Jira to Activate
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ from app.services.campaign_crud_service import (
|
|||||||
activate_campaign as crud_activate,
|
activate_campaign as crud_activate,
|
||||||
complete_campaign as crud_complete,
|
complete_campaign as crud_complete,
|
||||||
create_campaign as crud_create,
|
create_campaign as crud_create,
|
||||||
|
delete_campaign as crud_delete,
|
||||||
get_campaign_detail as crud_get_detail,
|
get_campaign_detail as crud_get_detail,
|
||||||
get_campaign_history as crud_get_history,
|
get_campaign_history as crud_get_history,
|
||||||
get_campaign_progress_data as crud_get_progress,
|
get_campaign_progress_data as crud_get_progress,
|
||||||
@@ -133,17 +134,6 @@ def create_campaign(
|
|||||||
)
|
)
|
||||||
uow.commit()
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -195,6 +185,37 @@ def update_campaign(
|
|||||||
return result
|
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
|
# POST /campaigns/{id}/tests — Add test to campaign
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -218,32 +239,6 @@ def add_test_to_campaign(
|
|||||||
)
|
)
|
||||||
uow.commit()
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -298,15 +293,18 @@ def activate_campaign(
|
|||||||
uow.commit()
|
uow.commit()
|
||||||
db.refresh(campaign)
|
db.refresh(campaign)
|
||||||
|
|
||||||
# Create Jira test tickets for any campaign tests that don't have one yet,
|
# Create Jira tickets for campaign and tests at activation time (non-fatal).
|
||||||
# nested under the campaign's Jira ticket (non-fatal).
|
# Campaign ticket is created here if it doesn't already exist (deferred from creation).
|
||||||
try:
|
try:
|
||||||
from app.services.jira_service import (
|
from app.services.jira_service import (
|
||||||
|
auto_create_campaign_issue,
|
||||||
auto_create_test_issue,
|
auto_create_test_issue,
|
||||||
get_campaign_jira_key,
|
get_campaign_jira_key,
|
||||||
get_test_jira_key,
|
get_test_jira_key,
|
||||||
)
|
)
|
||||||
campaign_jira_key = get_campaign_jira_key(db, campaign_id)
|
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:
|
if campaign_jira_key:
|
||||||
for ct in campaign.campaign_tests:
|
for ct in campaign.campaign_tests:
|
||||||
if ct.test and not get_test_jira_key(db, ct.test.id):
|
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,
|
db, ct.test, current_user,
|
||||||
parent_ticket_override=campaign_jira_key,
|
parent_ticket_override=campaign_jira_key,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Jira test ticket creation failed during activation of campaign %s",
|
"Jira ticket creation failed during activation of campaign %s",
|
||||||
campaign_id,
|
campaign_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -398,24 +396,6 @@ def generate_campaign_from_actor(
|
|||||||
)
|
)
|
||||||
uow.commit()
|
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)
|
return serialize_campaign(db, campaign)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -425,6 +425,62 @@ def schedule_campaign(
|
|||||||
return 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:
|
def get_campaign_history(db: Session, campaign_id: str) -> dict:
|
||||||
"""List all child campaigns (execution history) of a recurring campaign.
|
"""List all child campaigns (execution history) of a recurring campaign.
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,16 @@ export async function scheduleCampaign(
|
|||||||
return data;
|
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<void> {
|
||||||
|
await client.delete(`/campaigns/${campaignId}`, {
|
||||||
|
params: { delete_tests: deleteTests },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Get execution history (child campaigns) for a recurring campaign. */
|
/** Get execution history (child campaigns) for a recurring campaign. */
|
||||||
export async function getCampaignHistory(campaignId: string): Promise<{
|
export async function getCampaignHistory(campaignId: string): Promise<{
|
||||||
campaign_id: string;
|
campaign_id: string;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
getCampaign,
|
getCampaign,
|
||||||
activateCampaign,
|
activateCampaign,
|
||||||
completeCampaign,
|
completeCampaign,
|
||||||
|
deleteCampaign,
|
||||||
removeTestFromCampaign,
|
removeTestFromCampaign,
|
||||||
scheduleCampaign,
|
scheduleCampaign,
|
||||||
getCampaignHistory,
|
getCampaignHistory,
|
||||||
@@ -63,6 +64,8 @@ export default function CampaignDetailPage() {
|
|||||||
|
|
||||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||||
const [showAddTestModal, setShowAddTestModal] = useState(false);
|
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") => {
|
const showToast = (message: string, type: "success" | "error") => {
|
||||||
setToast({ message, type });
|
setToast({ message, type });
|
||||||
@@ -120,6 +123,18 @@ export default function CampaignDetailPage() {
|
|||||||
onError: (err: Error) => showToast(err.message, "error"),
|
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({
|
const { data: historyData } = useQuery({
|
||||||
queryKey: ["campaign-history", campaignId],
|
queryKey: ["campaign-history", campaignId],
|
||||||
queryFn: () => getCampaignHistory(campaignId!),
|
queryFn: () => getCampaignHistory(campaignId!),
|
||||||
@@ -249,6 +264,17 @@ export default function CampaignDetailPage() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Delete — only for draft campaigns (admins see it regardless) */}
|
||||||
|
{(campaign.status === "draft" || role === "admin") && canManage && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteStep(1)}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-900/20 px-3 py-2 text-sm font-medium text-red-400 hover:bg-red-900/40 transition-colors"
|
||||||
|
title="Delete campaign"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canManage && campaign.status === "draft" && (
|
{canManage && campaign.status === "draft" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => activateMutation.mutate()}
|
onClick={() => activateMutation.mutate()}
|
||||||
@@ -629,6 +655,87 @@ export default function CampaignDetailPage() {
|
|||||||
{toast.message}
|
{toast.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation — Step 1 */}
|
||||||
|
{deleteStep === 1 && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||||
|
<div className="mx-4 w-full max-w-md rounded-xl border border-red-500/30 bg-gray-900 p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-red-500/10 p-2">
|
||||||
|
<Trash2 className="h-5 w-5 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">Delete Campaign</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mb-1 text-sm text-gray-300">
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<span className="font-semibold text-white">{campaign.name}</span>?
|
||||||
|
</p>
|
||||||
|
<p className="mb-6 text-xs text-gray-500">This action cannot be undone.</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteStep(0)}
|
||||||
|
className="rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteStep(2)}
|
||||||
|
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation — Step 2: ask about tests */}
|
||||||
|
{deleteStep === 2 && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||||
|
<div className="mx-4 w-full max-w-md rounded-xl border border-red-500/30 bg-gray-900 p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-red-500/10 p-2">
|
||||||
|
<Trash2 className="h-5 w-5 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">Delete Associated Tests?</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mb-6 text-sm text-gray-300">
|
||||||
|
This campaign has{" "}
|
||||||
|
<span className="font-semibold text-white">{campaign.tests.length}</span>{" "}
|
||||||
|
associated test{campaign.tests.length !== 1 ? "s" : ""}. Do you also want to
|
||||||
|
delete them?
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteStep(0)}
|
||||||
|
className="rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMutation.mutate(false)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="flex items-center justify-center gap-1.5 rounded-lg border border-gray-600 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-200 hover:bg-gray-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Keep Tests
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMutation.mutate(true)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="flex items-center justify-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Delete Tests Too
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user