fix(campaigns): start_date modal + hide future-campaign tests from queue
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Backend: activate endpoint returns 409 with structured warning when
start_date is in the future; accepts force=true to bypass.
test_crud_service: always excludes tests from draft campaigns with future
start_date so they do not appear in the team queue prematurely.

Frontend: catches 409 on activate and shows amber confirmation modal
with Keep scheduled / Activate now anyway options.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-06-04 14:05:58 +02:00
parent f8418bc7ea
commit 4c230caa32
4 changed files with 91 additions and 19 deletions

View File

@@ -273,28 +273,33 @@ def remove_test_from_campaign(
@router.post("/{campaign_id}/activate") @router.post("/{campaign_id}/activate")
def activate_campaign( def activate_campaign(
campaign_id: str, campaign_id: str,
force: bool = Query(False, description="Activate even if start_date is in the future"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Activate a campaign, moving it from draft to active. """Activate a campaign, moving it from draft to active.
If the campaign has a start_date in the future, manual activation is blocked — If the campaign has a start_date in the future and force=False, returns a 409
the campaign will be auto-activated by the scheduler when the date arrives. with a warning so the frontend can show a confirmation modal. If force=True,
activates immediately regardless of start_date.
""" """
# Guard: start_date must have been reached before manual activation from fastapi import HTTPException
campaign_obj = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign_obj = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if campaign_obj and campaign_obj.start_date: if campaign_obj and campaign_obj.start_date and not force:
now = datetime.utcnow() now = datetime.utcnow()
if campaign_obj.start_date > now: if campaign_obj.start_date > now:
from fastapi import HTTPException
raise HTTPException( raise HTTPException(
status_code=422, status_code=409,
detail=( detail={
f"This campaign is scheduled to start on " "code": "start_date_in_future",
f"{campaign_obj.start_date.strftime('%Y-%m-%d')}. " "start_date": campaign_obj.start_date.strftime("%Y-%m-%d"),
f"It will be activated automatically on that date. " "message": (
f"To activate it now, remove the start date first." f"This campaign is scheduled to start on "
), f"{campaign_obj.start_date.strftime('%d %b %Y')}. "
f"It will activate automatically on that date. "
f"Do you want to activate it now anyway?"
),
},
) )
with UnitOfWork(db) as uow: with UnitOfWork(db) as uow:

View File

@@ -19,7 +19,7 @@ from app.models.enums import TestState
from app.models.technique import Technique from app.models.technique import Technique
from app.models.test import Test from app.models.test import Test
from app.models.test_template import TestTemplate from app.models.test_template import TestTemplate
from app.models.campaign import CampaignTest from app.models.campaign import Campaign, CampaignTest
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.utils import escape_like from app.utils import escape_like
@@ -36,7 +36,12 @@ def list_tests(
offset: int = 0, offset: int = 0,
limit: int = 50, limit: int = 50,
) -> list[Test]: ) -> list[Test]:
"""Return a paginated list of tests with optional filters.""" """Return a paginated list of tests with optional filters.
Tests that belong to a campaign still in 'draft' status AND with a
start_date in the future are always excluded — they should not appear
in the team's queue until the campaign is activated on its start date.
"""
query = db.query(Test).options(joinedload(Test.technique)) query = db.query(Test).options(joinedload(Test.technique))
if state: if state:
@@ -61,6 +66,22 @@ def list_tests(
linked = db.query(CampaignTest.test_id).distinct().subquery() linked = db.query(CampaignTest.test_id).distinct().subquery()
query = query.filter(~Test.id.in_(linked)) query = query.filter(~Test.id.in_(linked))
# Always hide tests from scheduled campaigns that haven't started yet.
# A "scheduled-but-not-yet-active" campaign = draft status + start_date in future.
now = datetime.utcnow()
future_draft_tests = (
db.query(CampaignTest.test_id)
.join(Campaign, Campaign.id == CampaignTest.campaign_id)
.filter(
Campaign.status == "draft",
Campaign.start_date.isnot(None),
Campaign.start_date > now,
)
.distinct()
.subquery()
)
query = query.filter(~Test.id.in_(future_draft_tests))
return query.order_by(Test.created_at.desc()).offset(offset).limit(limit).all() return query.order_by(Test.created_at.desc()).offset(offset).limit(limit).all()

View File

@@ -134,8 +134,12 @@ export async function removeTestFromCampaign(
} }
/** Activate a campaign. */ /** Activate a campaign. */
export async function activateCampaign(campaignId: string): Promise<Campaign> { export async function activateCampaign(
const { data } = await client.post<Campaign>(`/campaigns/${campaignId}/activate`); campaignId: string,
options?: { force?: boolean },
): Promise<Campaign> {
const params = options?.force ? "?force=true" : "";
const { data } = await client.post<Campaign>(`/campaigns/${campaignId}/activate${params}`);
return data; return data;
} }

View File

@@ -68,6 +68,8 @@ export default function CampaignDetailPage() {
const [showAddTestModal, setShowAddTestModal] = useState(false); const [showAddTestModal, setShowAddTestModal] = useState(false);
// 0 = hidden, 1 = first confirmation, 2 = ask about tests // 0 = hidden, 1 = first confirmation, 2 = ask about tests
const [deleteStep, setDeleteStep] = useState<0 | 1 | 2>(0); const [deleteStep, setDeleteStep] = useState<0 | 1 | 2>(0);
// Start-date confirmation modal — shown when campaign has a future start_date
const [startDateWarning, setStartDateWarning] = useState<string | null>(null);
const showToast = (message: string, type: "success" | "error") => { const showToast = (message: string, type: "success" | "error") => {
setToast({ message, type }); setToast({ message, type });
@@ -89,12 +91,21 @@ export default function CampaignDetailPage() {
}); });
const activateMutation = useMutation({ const activateMutation = useMutation({
mutationFn: () => activateCampaign(campaignId!), mutationFn: (force = false) => activateCampaign(campaignId!, force ? { force: true } : undefined),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] }); queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
setStartDateWarning(null);
showToast("Campaign activated", "success"); showToast("Campaign activated", "success");
}, },
onError: (err: Error) => showToast(err.message, "error"), onError: (err: unknown) => {
// 409 = future start_date warning → show confirmation modal instead of toast
const axiosErr = err as { response?: { status?: number; data?: { message?: string; start_date?: string } } };
if (axiosErr?.response?.status === 409 && axiosErr.response.data?.message) {
setStartDateWarning(axiosErr.response.data.message);
} else {
showToast((err as Error).message, "error");
}
},
}); });
const completeMutation = useMutation({ const completeMutation = useMutation({
@@ -297,7 +308,7 @@ export default function CampaignDetailPage() {
)} )}
{canManage && campaign.status === "draft" && ( {canManage && campaign.status === "draft" && (
<button <button
onClick={() => activateMutation.mutate()} onClick={() => activateMutation.mutate(false)}
disabled={activateMutation.isPending} disabled={activateMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors" className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
> >
@@ -666,6 +677,37 @@ export default function CampaignDetailPage() {
onSuccess={() => showToast("Test added to campaign", "success")} onSuccess={() => showToast("Test added to campaign", "success")}
/> />
{/* Start-date confirmation modal */}
{startDateWarning && (
<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-amber-500/30 bg-gray-900 p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-lg bg-amber-500/10 p-2">
<Clock className="h-5 w-5 text-amber-400" />
</div>
<h3 className="text-lg font-semibold text-white">Campaign not started yet</h3>
</div>
<p className="mb-6 text-sm text-gray-300 leading-relaxed">{startDateWarning}</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setStartDateWarning(null)}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
>
Keep scheduled
</button>
<button
onClick={() => activateMutation.mutate(true)}
disabled={activateMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500 disabled:opacity-50 transition-colors"
>
{activateMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Activate now anyway
</button>
</div>
</div>
</div>
)}
{/* Toast notification */} {/* Toast notification */}
{toast && ( {toast && (
<div <div