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")
def activate_campaign(
campaign_id: str,
force: bool = Query(False, description="Activate even if start_date is in the future"),
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Activate a campaign, moving it from draft to active.
If the campaign has a start_date in the future, manual activation is blocked —
the campaign will be auto-activated by the scheduler when the date arrives.
If the campaign has a start_date in the future and force=False, returns a 409
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()
if campaign_obj and campaign_obj.start_date:
if campaign_obj and campaign_obj.start_date and not force:
now = datetime.utcnow()
if campaign_obj.start_date > now:
from fastapi import HTTPException
raise HTTPException(
status_code=422,
detail=(
f"This campaign is scheduled to start on "
f"{campaign_obj.start_date.strftime('%Y-%m-%d')}. "
f"It will be activated automatically on that date. "
f"To activate it now, remove the start date first."
),
status_code=409,
detail={
"code": "start_date_in_future",
"start_date": campaign_obj.start_date.strftime("%Y-%m-%d"),
"message": (
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:

View File

@@ -19,7 +19,7 @@ from app.models.enums import TestState
from app.models.technique import Technique
from app.models.test import Test
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.utils import escape_like
@@ -36,7 +36,12 @@ def list_tests(
offset: int = 0,
limit: int = 50,
) -> 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))
if state:
@@ -61,6 +66,22 @@ def list_tests(
linked = db.query(CampaignTest.test_id).distinct().subquery()
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()