feat(phase-31): add campaign scheduling and recurring automation (T-233 to T-234)
This commit is contained in:
@@ -25,6 +25,7 @@ from app.services.campaign_service import (
|
||||
get_campaign_progress,
|
||||
generate_campaign_from_threat_actor,
|
||||
)
|
||||
from app.services.campaign_scheduler_service import calculate_next_run
|
||||
from app.services.notification_service import create_notification
|
||||
from app.services.audit_service import log_action
|
||||
|
||||
@@ -59,6 +60,12 @@ class AddTestPayload(BaseModel):
|
||||
phase: Optional[str] = None
|
||||
|
||||
|
||||
class SchedulePayload(BaseModel):
|
||||
is_recurring: bool
|
||||
recurrence_pattern: Optional[str] = None # weekly, monthly, quarterly
|
||||
next_run_at: Optional[str] = None
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _serialize_campaign(db: Session, campaign: Campaign) -> dict:
|
||||
@@ -107,6 +114,11 @@ def _serialize_campaign(db: Session, campaign: Campaign) -> dict:
|
||||
"target_platform": campaign.target_platform,
|
||||
"tags": campaign.tags or [],
|
||||
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
|
||||
"is_recurring": campaign.is_recurring or False,
|
||||
"recurrence_pattern": campaign.recurrence_pattern,
|
||||
"next_run_at": campaign.next_run_at.isoformat() if campaign.next_run_at else None,
|
||||
"last_run_at": campaign.last_run_at.isoformat() if campaign.last_run_at else None,
|
||||
"parent_campaign_id": str(campaign.parent_campaign_id) if campaign.parent_campaign_id else None,
|
||||
"tests": tests,
|
||||
"progress": progress,
|
||||
}
|
||||
@@ -128,6 +140,10 @@ def _serialize_campaign_summary(db: Session, campaign: Campaign) -> dict:
|
||||
"target_platform": campaign.target_platform,
|
||||
"tags": campaign.tags or [],
|
||||
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
|
||||
"is_recurring": campaign.is_recurring or False,
|
||||
"recurrence_pattern": campaign.recurrence_pattern,
|
||||
"next_run_at": campaign.next_run_at.isoformat() if campaign.next_run_at else None,
|
||||
"last_run_at": campaign.last_run_at.isoformat() if campaign.last_run_at else None,
|
||||
"test_count": progress["total"],
|
||||
"completion_pct": progress["completion_pct"],
|
||||
}
|
||||
@@ -522,3 +538,102 @@ def generate_campaign_from_actor(
|
||||
)
|
||||
|
||||
return _serialize_campaign(db, campaign)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /campaigns/{id}/schedule — Configure recurrence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.patch("/{campaign_id}/schedule")
|
||||
def schedule_campaign(
|
||||
campaign_id: str,
|
||||
payload: SchedulePayload,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("admin")),
|
||||
):
|
||||
"""Configure or update the recurrence schedule for a campaign.
|
||||
|
||||
Only the campaign creator or admin can change scheduling.
|
||||
"""
|
||||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
# Check ownership or admin
|
||||
if str(campaign.created_by) != str(current_user.id) and current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Only the creator or admin can configure scheduling")
|
||||
|
||||
campaign.is_recurring = payload.is_recurring
|
||||
|
||||
if payload.is_recurring:
|
||||
if payload.recurrence_pattern not in ("weekly", "monthly", "quarterly"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="recurrence_pattern must be 'weekly', 'monthly', or 'quarterly'",
|
||||
)
|
||||
campaign.recurrence_pattern = payload.recurrence_pattern
|
||||
if payload.next_run_at:
|
||||
campaign.next_run_at = datetime.fromisoformat(payload.next_run_at.replace("Z", "+00:00").replace("+00:00", ""))
|
||||
elif not campaign.next_run_at:
|
||||
campaign.next_run_at = calculate_next_run(datetime.utcnow(), payload.recurrence_pattern)
|
||||
else:
|
||||
campaign.recurrence_pattern = None
|
||||
campaign.next_run_at = None
|
||||
|
||||
db.commit()
|
||||
db.refresh(campaign)
|
||||
|
||||
log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
action="schedule_campaign",
|
||||
entity_type="campaign",
|
||||
entity_id=campaign.id,
|
||||
details={
|
||||
"is_recurring": campaign.is_recurring,
|
||||
"recurrence_pattern": campaign.recurrence_pattern,
|
||||
"next_run_at": campaign.next_run_at.isoformat() if campaign.next_run_at else None,
|
||||
},
|
||||
)
|
||||
|
||||
return _serialize_campaign(db, campaign)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /campaigns/{id}/history — Execution history (child campaigns)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{campaign_id}/history")
|
||||
def get_campaign_history(
|
||||
campaign_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all child campaigns (execution history) of a recurring campaign."""
|
||||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
children = (
|
||||
db.query(Campaign)
|
||||
.filter(Campaign.parent_campaign_id == campaign_id)
|
||||
.order_by(Campaign.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"campaign_id": str(campaign.id),
|
||||
"campaign_name": campaign.name,
|
||||
"items": [
|
||||
{
|
||||
"id": str(child.id),
|
||||
"name": child.name,
|
||||
"status": child.status,
|
||||
"test_count": db.query(CampaignTest).filter(CampaignTest.campaign_id == child.id).count(),
|
||||
"completion_pct": get_campaign_progress(db, child.id)["completion_pct"],
|
||||
"created_at": child.created_at.isoformat() if child.created_at else None,
|
||||
"completed_at": child.completed_at.isoformat() if child.completed_at else None,
|
||||
}
|
||||
for child in children
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user